eventsourcing 9.2.22__py3-none-any.whl → 9.3.0a1__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.
Potentially problematic release.
This version of eventsourcing might be problematic. Click here for more details.
- eventsourcing/__init__.py +1 -1
- eventsourcing/application.py +106 -135
- eventsourcing/cipher.py +15 -12
- eventsourcing/dispatch.py +31 -91
- eventsourcing/domain.py +138 -143
- eventsourcing/examples/__init__.py +0 -0
- eventsourcing/examples/aggregate1/__init__.py +0 -0
- eventsourcing/examples/aggregate1/application.py +27 -0
- eventsourcing/examples/aggregate1/domainmodel.py +16 -0
- eventsourcing/examples/aggregate1/test_application.py +37 -0
- eventsourcing/examples/aggregate2/__init__.py +0 -0
- eventsourcing/examples/aggregate2/application.py +27 -0
- eventsourcing/examples/aggregate2/domainmodel.py +22 -0
- eventsourcing/examples/aggregate2/test_application.py +37 -0
- eventsourcing/examples/aggregate3/__init__.py +0 -0
- eventsourcing/examples/aggregate3/application.py +27 -0
- eventsourcing/examples/aggregate3/domainmodel.py +38 -0
- eventsourcing/examples/aggregate3/test_application.py +37 -0
- eventsourcing/examples/aggregate4/__init__.py +0 -0
- eventsourcing/examples/aggregate4/application.py +27 -0
- eventsourcing/examples/aggregate4/domainmodel.py +128 -0
- eventsourcing/examples/aggregate4/test_application.py +38 -0
- eventsourcing/examples/aggregate5/__init__.py +0 -0
- eventsourcing/examples/aggregate5/application.py +27 -0
- eventsourcing/examples/aggregate5/domainmodel.py +131 -0
- eventsourcing/examples/aggregate5/test_application.py +38 -0
- eventsourcing/examples/aggregate6/__init__.py +0 -0
- eventsourcing/examples/aggregate6/application.py +30 -0
- eventsourcing/examples/aggregate6/domainmodel.py +123 -0
- eventsourcing/examples/aggregate6/test_application.py +38 -0
- eventsourcing/examples/aggregate6a/__init__.py +0 -0
- eventsourcing/examples/aggregate6a/application.py +40 -0
- eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
- eventsourcing/examples/aggregate6a/test_application.py +45 -0
- eventsourcing/examples/aggregate7/__init__.py +0 -0
- eventsourcing/examples/aggregate7/application.py +48 -0
- eventsourcing/examples/aggregate7/domainmodel.py +144 -0
- eventsourcing/examples/aggregate7/persistence.py +57 -0
- eventsourcing/examples/aggregate7/test_application.py +38 -0
- eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
- eventsourcing/examples/aggregate7a/__init__.py +0 -0
- eventsourcing/examples/aggregate7a/application.py +56 -0
- eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
- eventsourcing/examples/aggregate7a/test_application.py +46 -0
- eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate8/__init__.py +0 -0
- eventsourcing/examples/aggregate8/application.py +47 -0
- eventsourcing/examples/aggregate8/domainmodel.py +65 -0
- eventsourcing/examples/aggregate8/persistence.py +57 -0
- eventsourcing/examples/aggregate8/test_application.py +37 -0
- eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
- eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
- eventsourcing/examples/bankaccounts/__init__.py +0 -0
- eventsourcing/examples/bankaccounts/application.py +70 -0
- eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
- eventsourcing/examples/bankaccounts/test.py +173 -0
- eventsourcing/examples/cargoshipping/__init__.py +0 -0
- eventsourcing/examples/cargoshipping/application.py +126 -0
- eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
- eventsourcing/examples/cargoshipping/interface.py +143 -0
- eventsourcing/examples/cargoshipping/test.py +231 -0
- eventsourcing/examples/contentmanagement/__init__.py +0 -0
- eventsourcing/examples/contentmanagement/application.py +118 -0
- eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
- eventsourcing/examples/contentmanagement/test.py +180 -0
- eventsourcing/examples/contentmanagement/utils.py +26 -0
- eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
- eventsourcing/examples/contentmanagementsystem/application.py +54 -0
- eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
- eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
- eventsourcing/examples/contentmanagementsystem/system.py +14 -0
- eventsourcing/examples/contentmanagementsystem/test_system.py +174 -0
- eventsourcing/examples/searchablecontent/__init__.py +0 -0
- eventsourcing/examples/searchablecontent/application.py +45 -0
- eventsourcing/examples/searchablecontent/persistence.py +23 -0
- eventsourcing/examples/searchablecontent/postgres.py +118 -0
- eventsourcing/examples/searchablecontent/sqlite.py +136 -0
- eventsourcing/examples/searchablecontent/test_application.py +111 -0
- eventsourcing/examples/searchablecontent/test_recorder.py +69 -0
- eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
- eventsourcing/examples/searchabletimestamps/application.py +32 -0
- eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
- eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
- eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
- eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +91 -0
- eventsourcing/examples/test_invoice.py +176 -0
- eventsourcing/examples/test_parking_lot.py +206 -0
- eventsourcing/interface.py +2 -2
- eventsourcing/persistence.py +85 -81
- eventsourcing/popo.py +30 -31
- eventsourcing/postgres.py +361 -578
- eventsourcing/sqlite.py +91 -99
- eventsourcing/system.py +42 -57
- eventsourcing/tests/application.py +20 -32
- eventsourcing/tests/application_tests/__init__.py +0 -0
- eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
- eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
- eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
- eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
- eventsourcing/tests/application_tests/test_cache.py +134 -0
- eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
- eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
- eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
- eventsourcing/tests/application_tests/test_processapplication.py +110 -0
- eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
- eventsourcing/tests/application_tests/test_repository.py +504 -0
- eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
- eventsourcing/tests/application_tests/test_upcasting.py +459 -0
- eventsourcing/tests/docs_tests/__init__.py +0 -0
- eventsourcing/tests/docs_tests/test_docs.py +293 -0
- eventsourcing/tests/domain.py +1 -1
- eventsourcing/tests/domain_tests/__init__.py +0 -0
- eventsourcing/tests/domain_tests/test_aggregate.py +1159 -0
- eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
- eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
- eventsourcing/tests/interface_tests/__init__.py +0 -0
- eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
- eventsourcing/tests/persistence.py +49 -50
- eventsourcing/tests/persistence_tests/__init__.py +0 -0
- eventsourcing/tests/persistence_tests/test_aes.py +93 -0
- eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
- eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
- eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
- eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
- eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
- eventsourcing/tests/persistence_tests/test_popo.py +124 -0
- eventsourcing/tests/persistence_tests/test_postgres.py +1121 -0
- eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
- eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
- eventsourcing/tests/postgres_utils.py +7 -7
- eventsourcing/tests/system_tests/__init__.py +0 -0
- eventsourcing/tests/system_tests/test_runner.py +935 -0
- eventsourcing/tests/system_tests/test_system.py +287 -0
- eventsourcing/tests/utils_tests/__init__.py +0 -0
- eventsourcing/tests/utils_tests/test_utils.py +226 -0
- eventsourcing/utils.py +47 -50
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/METADATA +28 -80
- eventsourcing-9.3.0a1.dist-info/RECORD +144 -0
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/WHEEL +1 -2
- eventsourcing-9.2.22.dist-info/AUTHORS +0 -10
- eventsourcing-9.2.22.dist-info/RECORD +0 -25
- eventsourcing-9.2.22.dist-info/top_level.txt +0 -1
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, List, Tuple, cast
|
|
4
|
+
|
|
5
|
+
from eventsourcing.examples.contentmanagement.application import (
|
|
6
|
+
ContentManagementApplication,
|
|
7
|
+
PageDetailsType,
|
|
8
|
+
)
|
|
9
|
+
from eventsourcing.examples.contentmanagement.domainmodel import Page
|
|
10
|
+
from eventsourcing.examples.searchablecontent.persistence import (
|
|
11
|
+
SearchableContentRecorder,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from eventsourcing.domain import DomainEventProtocol, MutableOrImmutableAggregate
|
|
18
|
+
from eventsourcing.persistence import Recording
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SearchableContentApplication(ContentManagementApplication):
|
|
22
|
+
def save(
|
|
23
|
+
self,
|
|
24
|
+
*objs: MutableOrImmutableAggregate | DomainEventProtocol | None,
|
|
25
|
+
**kwargs: Any,
|
|
26
|
+
) -> List[Recording]:
|
|
27
|
+
insert_pages: List[Tuple[UUID, str, str, str]] = []
|
|
28
|
+
update_pages: List[Tuple[UUID, str, str, str]] = []
|
|
29
|
+
for obj in objs:
|
|
30
|
+
if isinstance(obj, Page):
|
|
31
|
+
if obj.version == len(obj.pending_events):
|
|
32
|
+
insert_pages.append((obj.id, obj.slug, obj.title, obj.body))
|
|
33
|
+
else:
|
|
34
|
+
update_pages.append((obj.id, obj.slug, obj.title, obj.body))
|
|
35
|
+
kwargs["insert_pages"] = insert_pages
|
|
36
|
+
kwargs["update_pages"] = update_pages
|
|
37
|
+
return super().save(*objs, **kwargs)
|
|
38
|
+
|
|
39
|
+
def search(self, query: str) -> List[PageDetailsType]:
|
|
40
|
+
pages = []
|
|
41
|
+
recorder = cast(SearchableContentRecorder, self.recorder)
|
|
42
|
+
for page_id in recorder.search_pages(query):
|
|
43
|
+
page = self.get_page_by_id(page_id)
|
|
44
|
+
pages.append(page)
|
|
45
|
+
return pages
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, List, Tuple
|
|
5
|
+
|
|
6
|
+
from eventsourcing.persistence import AggregateRecorder
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SearchableContentRecorder(AggregateRecorder):
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def search_pages(self, query: str) -> List[UUID]:
|
|
15
|
+
"""
|
|
16
|
+
Returns IDs for pages that match query.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def select_page(self, page_id: UUID) -> Tuple[str, str, str]:
|
|
21
|
+
"""
|
|
22
|
+
Returns slug, title and body for given ID.
|
|
23
|
+
"""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, List, Sequence, Tuple
|
|
4
|
+
|
|
5
|
+
from eventsourcing.examples.contentmanagement.application import PageNotFoundError
|
|
6
|
+
from eventsourcing.examples.searchablecontent.persistence import (
|
|
7
|
+
SearchableContentRecorder,
|
|
8
|
+
)
|
|
9
|
+
from eventsourcing.postgres import (
|
|
10
|
+
Factory,
|
|
11
|
+
PostgresAggregateRecorder,
|
|
12
|
+
PostgresApplicationRecorder,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from psycopg import Cursor
|
|
19
|
+
from psycopg.rows import DictRow
|
|
20
|
+
|
|
21
|
+
from eventsourcing.persistence import StoredEvent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PostgresSearchableContentRecorder(
|
|
25
|
+
SearchableContentRecorder,
|
|
26
|
+
PostgresAggregateRecorder,
|
|
27
|
+
):
|
|
28
|
+
pages_table_name = "pages_projection_example"
|
|
29
|
+
select_page_statement = (
|
|
30
|
+
f"SELECT page_slug, page_title, page_body FROM {pages_table_name}"
|
|
31
|
+
" WHERE page_id = %s"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
insert_page_statement = f"INSERT INTO {pages_table_name} VALUES (%s, %s, %s, %s)"
|
|
35
|
+
|
|
36
|
+
update_page_statement = (
|
|
37
|
+
f"UPDATE {pages_table_name}"
|
|
38
|
+
" SET page_slug = %s, page_title = %s, page_body = %s WHERE page_id = %s"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
search_pages_statement = (
|
|
42
|
+
f"SELECT page_id FROM {pages_table_name} WHERE"
|
|
43
|
+
" to_tsvector('english', page_body) @@ websearch_to_tsquery('english', %s)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def construct_create_table_statements(self) -> List[str]:
|
|
47
|
+
statements = super().construct_create_table_statements()
|
|
48
|
+
statements.append(
|
|
49
|
+
"CREATE TABLE IF NOT EXISTS "
|
|
50
|
+
f"{self.pages_table_name} ("
|
|
51
|
+
"page_id uuid, "
|
|
52
|
+
"page_slug text, "
|
|
53
|
+
"page_title text, "
|
|
54
|
+
"page_body text, "
|
|
55
|
+
"PRIMARY KEY "
|
|
56
|
+
"(page_id))"
|
|
57
|
+
)
|
|
58
|
+
statements.append(
|
|
59
|
+
f"CREATE INDEX IF NOT EXISTS {self.pages_table_name}_idx "
|
|
60
|
+
f"ON {self.pages_table_name} "
|
|
61
|
+
"USING GIN (to_tsvector('english', page_body))"
|
|
62
|
+
)
|
|
63
|
+
return statements
|
|
64
|
+
|
|
65
|
+
def _insert_events(
|
|
66
|
+
self,
|
|
67
|
+
c: Cursor[DictRow],
|
|
68
|
+
stored_events: List[StoredEvent],
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._insert_pages(c, **kwargs)
|
|
72
|
+
self._update_pages(c, **kwargs)
|
|
73
|
+
super()._insert_events(c, stored_events, **kwargs)
|
|
74
|
+
|
|
75
|
+
def _insert_pages(
|
|
76
|
+
self,
|
|
77
|
+
c: Cursor[DictRow],
|
|
78
|
+
insert_pages: Sequence[Tuple[UUID, str, str, str]] = (),
|
|
79
|
+
**_: Any,
|
|
80
|
+
) -> None:
|
|
81
|
+
for params in insert_pages:
|
|
82
|
+
c.execute(self.insert_page_statement, params, prepare=True)
|
|
83
|
+
|
|
84
|
+
def _update_pages(
|
|
85
|
+
self,
|
|
86
|
+
c: Cursor[DictRow],
|
|
87
|
+
update_pages: Sequence[Tuple[UUID, str, str, str]] = (),
|
|
88
|
+
**_: Any,
|
|
89
|
+
) -> None:
|
|
90
|
+
for page_id, page_slug, page_title, page_body in update_pages:
|
|
91
|
+
params = (page_slug, page_title, page_body, page_id)
|
|
92
|
+
c.execute(self.update_page_statement, params, prepare=True)
|
|
93
|
+
|
|
94
|
+
def search_pages(self, query: str) -> List[UUID]:
|
|
95
|
+
with self.datastore.transaction(commit=False) as curs:
|
|
96
|
+
curs.execute(self.search_pages_statement, [query], prepare=True)
|
|
97
|
+
return [row["page_id"] for row in curs.fetchall()]
|
|
98
|
+
|
|
99
|
+
def select_page(self, page_id: UUID) -> Tuple[str, str, str]:
|
|
100
|
+
with self.datastore.transaction(commit=False) as curs:
|
|
101
|
+
curs.execute(self.select_page_statement, [str(page_id)], prepare=True)
|
|
102
|
+
for row in curs.fetchall():
|
|
103
|
+
return row["page_slug"], row["page_title"], row["page_body"]
|
|
104
|
+
msg = f"Page ID {page_id} not found"
|
|
105
|
+
raise PageNotFoundError(msg)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SearchableContentApplicationRecorder(
|
|
109
|
+
PostgresSearchableContentRecorder, PostgresApplicationRecorder
|
|
110
|
+
):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SearchableContentInfrastructureFactory(Factory):
|
|
115
|
+
application_recorder_class = SearchableContentApplicationRecorder
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
del Factory
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, List, Sequence, Tuple
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from eventsourcing.examples.contentmanagement.application import PageNotFoundError
|
|
7
|
+
from eventsourcing.examples.searchablecontent.persistence import (
|
|
8
|
+
SearchableContentRecorder,
|
|
9
|
+
)
|
|
10
|
+
from eventsourcing.sqlite import (
|
|
11
|
+
Factory,
|
|
12
|
+
SQLiteAggregateRecorder,
|
|
13
|
+
SQLiteApplicationRecorder,
|
|
14
|
+
SQLiteCursor,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
18
|
+
from eventsourcing.persistence import StoredEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SQLiteSearchableContentRecorder(
|
|
22
|
+
SearchableContentRecorder, SQLiteAggregateRecorder
|
|
23
|
+
):
|
|
24
|
+
pages_table_name = "pages_projection_example"
|
|
25
|
+
pages_virtual_table_name = pages_table_name + "_fts"
|
|
26
|
+
select_page_statement = (
|
|
27
|
+
"SELECT page_slug, page_title, page_body FROM "
|
|
28
|
+
f"{pages_table_name} WHERE page_id = ?"
|
|
29
|
+
)
|
|
30
|
+
insert_page_statement = f"INSERT INTO {pages_table_name} VALUES (?, ?, ?, ?)"
|
|
31
|
+
update_page_statement = (
|
|
32
|
+
f"UPDATE {pages_table_name} "
|
|
33
|
+
"SET page_slug = ?, page_title = ?, page_body = ? WHERE page_id = ?"
|
|
34
|
+
)
|
|
35
|
+
search_pages_statement = (
|
|
36
|
+
f"SELECT page_id FROM {pages_virtual_table_name} WHERE page_body MATCH ?"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def construct_create_table_statements(self) -> List[str]:
|
|
40
|
+
statements = super().construct_create_table_statements()
|
|
41
|
+
statements.append(
|
|
42
|
+
"CREATE TABLE IF NOT EXISTS "
|
|
43
|
+
f"{self.pages_table_name} ("
|
|
44
|
+
"page_id TEXT, "
|
|
45
|
+
"page_slug TEXT, "
|
|
46
|
+
"page_title TEXT, "
|
|
47
|
+
"page_body TEXT, "
|
|
48
|
+
"PRIMARY KEY "
|
|
49
|
+
"(page_id)) "
|
|
50
|
+
)
|
|
51
|
+
statements.append(
|
|
52
|
+
f"CREATE VIRTUAL TABLE {self.pages_virtual_table_name} USING fts5("
|
|
53
|
+
f"page_id, page_body, content='{self.pages_table_name}')"
|
|
54
|
+
)
|
|
55
|
+
statements.append(
|
|
56
|
+
"CREATE TRIGGER projection_ai AFTER INSERT ON "
|
|
57
|
+
f"{self.pages_table_name} BEGIN "
|
|
58
|
+
f"INSERT INTO {self.pages_virtual_table_name} "
|
|
59
|
+
"(rowid, page_id, page_body) "
|
|
60
|
+
"VALUES (new.rowid, new.page_id, new.page_body); "
|
|
61
|
+
"END"
|
|
62
|
+
)
|
|
63
|
+
statements.append(
|
|
64
|
+
"CREATE TRIGGER projection_au AFTER UPDATE ON "
|
|
65
|
+
f"{self.pages_table_name} "
|
|
66
|
+
"BEGIN "
|
|
67
|
+
f"INSERT INTO {self.pages_virtual_table_name} "
|
|
68
|
+
f"({self.pages_virtual_table_name}, rowid, page_id, page_body) "
|
|
69
|
+
"VALUES ('delete', old.rowid, old.page_id, old.page_body);"
|
|
70
|
+
f"INSERT INTO {self.pages_virtual_table_name} "
|
|
71
|
+
"(rowid, page_id, page_body) "
|
|
72
|
+
"VALUES (new.rowid, new.page_id, new.page_body); "
|
|
73
|
+
"END"
|
|
74
|
+
)
|
|
75
|
+
return statements
|
|
76
|
+
|
|
77
|
+
def _insert_events(
|
|
78
|
+
self,
|
|
79
|
+
c: SQLiteCursor,
|
|
80
|
+
stored_events: List[StoredEvent],
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> Sequence[int] | None:
|
|
83
|
+
notification_ids = super()._insert_events(c, stored_events, **kwargs)
|
|
84
|
+
self._insert_pages(c, **kwargs)
|
|
85
|
+
self._update_pages(c, **kwargs)
|
|
86
|
+
return notification_ids
|
|
87
|
+
|
|
88
|
+
def _insert_pages(
|
|
89
|
+
self,
|
|
90
|
+
c: SQLiteCursor,
|
|
91
|
+
insert_pages: Sequence[Tuple[UUID, str, str, str]] = (),
|
|
92
|
+
**_: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
for page_id, page_slug, page_title, page_body in insert_pages:
|
|
95
|
+
c.execute(
|
|
96
|
+
self.insert_page_statement,
|
|
97
|
+
(str(page_id), page_slug, page_title, page_body),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _update_pages(
|
|
101
|
+
self,
|
|
102
|
+
c: SQLiteCursor,
|
|
103
|
+
update_pages: Sequence[Tuple[UUID, str, str, str]] = (),
|
|
104
|
+
**_: Any,
|
|
105
|
+
) -> None:
|
|
106
|
+
for page_id, page_slug, page_title, page_body in update_pages:
|
|
107
|
+
c.execute(
|
|
108
|
+
self.update_page_statement,
|
|
109
|
+
(page_slug, page_title, page_body, str(page_id)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def search_pages(self, query: str) -> List[UUID]:
|
|
113
|
+
with self.datastore.transaction(commit=False) as c:
|
|
114
|
+
c.execute(self.search_pages_statement, [query])
|
|
115
|
+
return [UUID(row["page_id"]) for row in c.fetchall()]
|
|
116
|
+
|
|
117
|
+
def select_page(self, page_id: UUID) -> Tuple[str, str, str]:
|
|
118
|
+
with self.datastore.transaction(commit=False) as c:
|
|
119
|
+
c.execute(self.select_page_statement, [str(page_id)])
|
|
120
|
+
for row in c.fetchall():
|
|
121
|
+
return row["page_slug"], row["page_title"], row["page_body"]
|
|
122
|
+
msg = f"Page ID {page_id} not found"
|
|
123
|
+
raise PageNotFoundError(msg)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SearchableContentApplicationRecorder(
|
|
127
|
+
SQLiteSearchableContentRecorder, SQLiteApplicationRecorder
|
|
128
|
+
):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SearchableContentInfrastructureFactory(Factory):
|
|
133
|
+
application_recorder_class = SearchableContentApplicationRecorder
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
del Factory
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import ClassVar, Dict
|
|
5
|
+
from unittest import TestCase
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from eventsourcing.examples.contentmanagement.domainmodel import user_id_cvar
|
|
9
|
+
from eventsourcing.examples.searchablecontent.application import (
|
|
10
|
+
SearchableContentApplication,
|
|
11
|
+
)
|
|
12
|
+
from eventsourcing.postgres import PostgresDatastore
|
|
13
|
+
from eventsourcing.tests.postgres_utils import drop_postgres_table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SearchableContentApplicationTestCase(TestCase):
|
|
17
|
+
env: ClassVar[Dict[str, str]] = {}
|
|
18
|
+
|
|
19
|
+
def test_app(self) -> None:
|
|
20
|
+
app = SearchableContentApplication(env=self.env)
|
|
21
|
+
|
|
22
|
+
# Set user_id context variable.
|
|
23
|
+
user_id = uuid4()
|
|
24
|
+
user_id_cvar.set(user_id)
|
|
25
|
+
|
|
26
|
+
# Create empty pages.
|
|
27
|
+
app.create_page(title="Animals", slug="animals")
|
|
28
|
+
app.create_page(title="Plants", slug="plants")
|
|
29
|
+
app.create_page(title="Minerals", slug="minerals")
|
|
30
|
+
|
|
31
|
+
# Search, expect no results.
|
|
32
|
+
self.assertEqual(0, len(app.search("dog")))
|
|
33
|
+
self.assertEqual(0, len(app.search("rose")))
|
|
34
|
+
self.assertEqual(0, len(app.search("zinc")))
|
|
35
|
+
|
|
36
|
+
# Update the pages.
|
|
37
|
+
app.update_body(slug="animals", body="cat dog zebra")
|
|
38
|
+
app.update_body(slug="plants", body="bluebell rose jasmine")
|
|
39
|
+
app.update_body(slug="minerals", body="iron zinc calcium")
|
|
40
|
+
|
|
41
|
+
# Search for single words, expect results.
|
|
42
|
+
pages = app.search("dog")
|
|
43
|
+
self.assertEqual(1, len(pages))
|
|
44
|
+
self.assertEqual(pages[0]["slug"], "animals")
|
|
45
|
+
self.assertEqual(pages[0]["body"], "cat dog zebra")
|
|
46
|
+
|
|
47
|
+
pages = app.search("rose")
|
|
48
|
+
self.assertEqual(1, len(pages))
|
|
49
|
+
self.assertEqual(pages[0]["slug"], "plants")
|
|
50
|
+
self.assertEqual(pages[0]["body"], "bluebell rose jasmine")
|
|
51
|
+
|
|
52
|
+
pages = app.search("zinc")
|
|
53
|
+
self.assertEqual(1, len(pages))
|
|
54
|
+
self.assertEqual(pages[0]["slug"], "minerals")
|
|
55
|
+
self.assertEqual(pages[0]["body"], "iron zinc calcium")
|
|
56
|
+
|
|
57
|
+
# Search for multiple words in same page.
|
|
58
|
+
pages = app.search("dog cat")
|
|
59
|
+
self.assertEqual(1, len(pages))
|
|
60
|
+
self.assertEqual(pages[0]["slug"], "animals")
|
|
61
|
+
self.assertEqual(pages[0]["body"], "cat dog zebra")
|
|
62
|
+
|
|
63
|
+
# Search for multiple words in same page, expect no results.
|
|
64
|
+
pages = app.search("rose zebra")
|
|
65
|
+
self.assertEqual(0, len(pages))
|
|
66
|
+
|
|
67
|
+
# Search for alternative words, expect two results.
|
|
68
|
+
pages = app.search("rose OR zebra")
|
|
69
|
+
self.assertEqual(2, len(pages))
|
|
70
|
+
self.assertEqual(["animals", "plants"], sorted(p["slug"] for p in pages))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestWithSQLite(SearchableContentApplicationTestCase):
|
|
74
|
+
env: ClassVar[Dict[str, str]] = {
|
|
75
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchablecontent.sqlite",
|
|
76
|
+
"SQLITE_DBNAME": ":memory:",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestWithPostgres(SearchableContentApplicationTestCase):
|
|
81
|
+
env: ClassVar[Dict[str, str]] = {
|
|
82
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchablecontent.postgres"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def setUp(self) -> None:
|
|
86
|
+
super().setUp()
|
|
87
|
+
os.environ["POSTGRES_DBNAME"] = "eventsourcing"
|
|
88
|
+
os.environ["POSTGRES_HOST"] = "127.0.0.1"
|
|
89
|
+
os.environ["POSTGRES_PORT"] = "5432"
|
|
90
|
+
os.environ["POSTGRES_USER"] = "eventsourcing"
|
|
91
|
+
os.environ["POSTGRES_PASSWORD"] = "eventsourcing" # noqa: S105
|
|
92
|
+
self.drop_tables()
|
|
93
|
+
|
|
94
|
+
def tearDown(self) -> None:
|
|
95
|
+
self.drop_tables()
|
|
96
|
+
super().tearDown()
|
|
97
|
+
|
|
98
|
+
def drop_tables(self) -> None:
|
|
99
|
+
db = PostgresDatastore(
|
|
100
|
+
os.environ["POSTGRES_DBNAME"],
|
|
101
|
+
os.environ["POSTGRES_HOST"],
|
|
102
|
+
os.environ["POSTGRES_PORT"],
|
|
103
|
+
os.environ["POSTGRES_USER"],
|
|
104
|
+
os.environ["POSTGRES_PASSWORD"],
|
|
105
|
+
)
|
|
106
|
+
drop_postgres_table(db, "public.searchablecontentapplication_events")
|
|
107
|
+
drop_postgres_table(db, "public.pages_projection_example")
|
|
108
|
+
db.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
del SearchableContentApplicationTestCase
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import ClassVar, Dict, cast
|
|
5
|
+
from unittest import TestCase
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from eventsourcing.examples.contentmanagement.application import PageNotFoundError
|
|
9
|
+
from eventsourcing.examples.searchablecontent.application import (
|
|
10
|
+
SearchableContentApplication,
|
|
11
|
+
)
|
|
12
|
+
from eventsourcing.examples.searchablecontent.persistence import (
|
|
13
|
+
SearchableContentRecorder,
|
|
14
|
+
)
|
|
15
|
+
from eventsourcing.postgres import PostgresDatastore
|
|
16
|
+
from eventsourcing.tests.postgres_utils import drop_postgres_table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchableContentRecorderTestCase(TestCase):
|
|
20
|
+
env: ClassVar[Dict[str, str]] = {}
|
|
21
|
+
|
|
22
|
+
def test_recorder(self) -> None:
|
|
23
|
+
app = SearchableContentApplication(env=self.env)
|
|
24
|
+
|
|
25
|
+
# Need to cover the case where select_page() raises PageNotFound.
|
|
26
|
+
recorder = cast(SearchableContentRecorder, app.recorder)
|
|
27
|
+
with self.assertRaises(PageNotFoundError):
|
|
28
|
+
recorder.select_page(uuid4())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestWithSQLite(SearchableContentRecorderTestCase):
|
|
32
|
+
env: ClassVar[Dict[str, str]] = {
|
|
33
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchablecontent.sqlite",
|
|
34
|
+
"SQLITE_DBNAME": ":memory:",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestWithPostgres(SearchableContentRecorderTestCase):
|
|
39
|
+
env: ClassVar[Dict[str, str]] = {
|
|
40
|
+
"PERSISTENCE_MODULE": "eventsourcing.examples.searchablecontent.postgres"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def setUp(self) -> None:
|
|
44
|
+
super().setUp()
|
|
45
|
+
os.environ["POSTGRES_DBNAME"] = "eventsourcing"
|
|
46
|
+
os.environ["POSTGRES_HOST"] = "127.0.0.1"
|
|
47
|
+
os.environ["POSTGRES_PORT"] = "5432"
|
|
48
|
+
os.environ["POSTGRES_USER"] = "eventsourcing"
|
|
49
|
+
os.environ["POSTGRES_PASSWORD"] = "eventsourcing" # noqa: S105
|
|
50
|
+
self.drop_tables()
|
|
51
|
+
|
|
52
|
+
def tearDown(self) -> None:
|
|
53
|
+
self.drop_tables()
|
|
54
|
+
super().tearDown()
|
|
55
|
+
|
|
56
|
+
def drop_tables(self) -> None:
|
|
57
|
+
db = PostgresDatastore(
|
|
58
|
+
os.environ["POSTGRES_DBNAME"],
|
|
59
|
+
os.environ["POSTGRES_HOST"],
|
|
60
|
+
os.environ["POSTGRES_PORT"],
|
|
61
|
+
os.environ["POSTGRES_USER"],
|
|
62
|
+
os.environ["POSTGRES_PASSWORD"],
|
|
63
|
+
)
|
|
64
|
+
drop_postgres_table(db, "public.searchablecontentapplication_events")
|
|
65
|
+
drop_postgres_table(db, "public.pages_projection_example")
|
|
66
|
+
db.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
del SearchableContentRecorderTestCase
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, List, cast
|
|
4
|
+
|
|
5
|
+
from eventsourcing.examples.cargoshipping.application import BookingApplication
|
|
6
|
+
from eventsourcing.examples.cargoshipping.domainmodel import Cargo
|
|
7
|
+
from eventsourcing.examples.searchabletimestamps.persistence import (
|
|
8
|
+
SearchableTimestampsRecorder,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from eventsourcing.application import ProcessingEvent
|
|
16
|
+
from eventsourcing.persistence import Recording
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchableTimestampsApplication(BookingApplication):
|
|
20
|
+
def _record(self, processing_event: ProcessingEvent) -> List[Recording]:
|
|
21
|
+
event_timestamps_data = [
|
|
22
|
+
(e.originator_id, e.timestamp, e.originator_version)
|
|
23
|
+
for e in processing_event.events
|
|
24
|
+
if isinstance(e, Cargo.Event)
|
|
25
|
+
]
|
|
26
|
+
processing_event.saved_kwargs["event_timestamps_data"] = event_timestamps_data
|
|
27
|
+
return super()._record(processing_event)
|
|
28
|
+
|
|
29
|
+
def get_cargo_at_timestamp(self, tracking_id: UUID, timestamp: datetime) -> Cargo:
|
|
30
|
+
recorder = cast(SearchableTimestampsRecorder, self.recorder)
|
|
31
|
+
version = recorder.get_version_at_timestamp(tracking_id, timestamp)
|
|
32
|
+
return cast(Cargo, self.repository.get(tracking_id, version=version))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from eventsourcing.persistence import ApplicationRecorder
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SearchableTimestampsRecorder(ApplicationRecorder):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_version_at_timestamp(
|
|
16
|
+
self, originator_id: UUID, timestamp: datetime
|
|
17
|
+
) -> int | None:
|
|
18
|
+
"""
|
|
19
|
+
Returns originator version at timestamp for given originator ID.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Any, List, Tuple, cast
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from eventsourcing.domain import Aggregate
|
|
8
|
+
from eventsourcing.examples.searchabletimestamps.persistence import (
|
|
9
|
+
SearchableTimestampsRecorder,
|
|
10
|
+
)
|
|
11
|
+
from eventsourcing.postgres import (
|
|
12
|
+
Factory,
|
|
13
|
+
PostgresApplicationRecorder,
|
|
14
|
+
PostgresDatastore,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
18
|
+
from psycopg import Cursor
|
|
19
|
+
from psycopg.rows import DictRow
|
|
20
|
+
|
|
21
|
+
from eventsourcing.persistence import ApplicationRecorder, StoredEvent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SearchableTimestampsApplicationRecorder(
|
|
25
|
+
SearchableTimestampsRecorder, PostgresApplicationRecorder
|
|
26
|
+
):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
datastore: PostgresDatastore,
|
|
30
|
+
events_table_name: str = "stored_events",
|
|
31
|
+
event_timestamps_table_name: str = "event_timestamps",
|
|
32
|
+
):
|
|
33
|
+
self.check_table_name_length(event_timestamps_table_name, datastore.schema)
|
|
34
|
+
self.event_timestamps_table_name = event_timestamps_table_name
|
|
35
|
+
super().__init__(datastore, events_table_name)
|
|
36
|
+
self.insert_event_timestamp_statement = (
|
|
37
|
+
f"INSERT INTO {self.event_timestamps_table_name} VALUES (%s, %s, %s)"
|
|
38
|
+
)
|
|
39
|
+
self.select_event_timestamp_statement = (
|
|
40
|
+
f"SELECT originator_version FROM {self.event_timestamps_table_name} WHERE "
|
|
41
|
+
"originator_id = %s AND "
|
|
42
|
+
"timestamp <= %s "
|
|
43
|
+
"ORDER BY originator_version DESC "
|
|
44
|
+
"LIMIT 1"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def construct_create_table_statements(self) -> List[str]:
|
|
48
|
+
statements = super().construct_create_table_statements()
|
|
49
|
+
statements.append(
|
|
50
|
+
"CREATE TABLE IF NOT EXISTS "
|
|
51
|
+
f"{self.event_timestamps_table_name} ("
|
|
52
|
+
"originator_id uuid NOT NULL, "
|
|
53
|
+
"timestamp timestamp with time zone, "
|
|
54
|
+
"originator_version bigint NOT NULL, "
|
|
55
|
+
"PRIMARY KEY "
|
|
56
|
+
"(originator_id, timestamp))"
|
|
57
|
+
)
|
|
58
|
+
return statements
|
|
59
|
+
|
|
60
|
+
def _insert_events(
|
|
61
|
+
self,
|
|
62
|
+
c: Cursor[DictRow],
|
|
63
|
+
stored_events: List[StoredEvent],
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> None:
|
|
66
|
+
# Insert event timestamps.
|
|
67
|
+
event_timestamps_data = cast(
|
|
68
|
+
List[Tuple[UUID, datetime, int]], kwargs.get("event_timestamps_data")
|
|
69
|
+
)
|
|
70
|
+
for event_timestamp_data in event_timestamps_data:
|
|
71
|
+
c.execute(
|
|
72
|
+
query=self.insert_event_timestamp_statement,
|
|
73
|
+
params=event_timestamp_data,
|
|
74
|
+
prepare=True,
|
|
75
|
+
)
|
|
76
|
+
super()._insert_events(c, stored_events, **kwargs)
|
|
77
|
+
|
|
78
|
+
def get_version_at_timestamp(
|
|
79
|
+
self, originator_id: UUID, timestamp: datetime
|
|
80
|
+
) -> int | None:
|
|
81
|
+
with self.datastore.transaction(commit=False) as curs:
|
|
82
|
+
curs.execute(
|
|
83
|
+
query=self.select_event_timestamp_statement,
|
|
84
|
+
params=(originator_id, timestamp),
|
|
85
|
+
prepare=True,
|
|
86
|
+
)
|
|
87
|
+
for row in curs.fetchall():
|
|
88
|
+
version = row["originator_version"]
|
|
89
|
+
break
|
|
90
|
+
else:
|
|
91
|
+
version = Aggregate.INITIAL_VERSION - 1
|
|
92
|
+
return version
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SearchableTimestampsInfrastructureFactory(Factory):
|
|
96
|
+
def application_recorder(self) -> ApplicationRecorder:
|
|
97
|
+
prefix = (self.datastore.schema + ".") if self.datastore.schema else ""
|
|
98
|
+
prefix += self.env.name.lower() or "stored"
|
|
99
|
+
events_table_name = prefix + "_events"
|
|
100
|
+
event_timestamps_table_name = prefix + "_timestamps"
|
|
101
|
+
recorder = SearchableTimestampsApplicationRecorder(
|
|
102
|
+
datastore=self.datastore,
|
|
103
|
+
events_table_name=events_table_name,
|
|
104
|
+
event_timestamps_table_name=event_timestamps_table_name,
|
|
105
|
+
)
|
|
106
|
+
recorder.create_table()
|
|
107
|
+
return recorder
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
del Factory
|