langgraph-store-postgres 0.1.0__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.
- langgraph_store_postgres-0.1.0/.gitignore +9 -0
- langgraph_store_postgres-0.1.0/PKG-INFO +68 -0
- langgraph_store_postgres-0.1.0/README.md +49 -0
- langgraph_store_postgres-0.1.0/pyproject.toml +37 -0
- langgraph_store_postgres-0.1.0/src/langgraph_store_postgres/__init__.py +164 -0
- langgraph_store_postgres-0.1.0/tests/test_postgres_semantic.py +103 -0
- langgraph_store_postgres-0.1.0/tests/test_postgres_store.py +119 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-store-postgres
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (pgvector) search
|
|
5
|
+
Project-URL: Homepage, https://github.com/skamalj/langgraph-store
|
|
6
|
+
Project-URL: Repository, https://github.com/skamalj/langgraph-store.git
|
|
7
|
+
Author-email: Kamal <skamalj@gmail.com>
|
|
8
|
+
Keywords: agent-memory,basestore,langgraph,long-term-memory,postgres,postgresql,store
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: langgraph-store-core>=0.1.0
|
|
15
|
+
Requires-Dist: pgvector>=0.2
|
|
16
|
+
Requires-Dist: psycopg2-binary
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# langgraph-store-postgres
|
|
21
|
+
|
|
22
|
+
A **PostgreSQL** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via pgvector**.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install langgraph-store-postgres
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from langgraph_store_postgres import PostgresStore
|
|
30
|
+
|
|
31
|
+
store = PostgresStore("postgresql://user:pass@localhost:5432/db") # or engine=<Engine>
|
|
32
|
+
|
|
33
|
+
store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
34
|
+
item = store.get(("users", "1", "memories"), "food")
|
|
35
|
+
hits = store.search(("users", "1"), filter={"kind": "pref"}, limit=10)
|
|
36
|
+
spaces = store.list_namespaces(prefix=("users",))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use it as a LangGraph store: `graph.compile(store=PostgresStore(...))`. Async methods (`aget`/`aput`/`asearch`/…) work too — the sync calls run in a thread.
|
|
40
|
+
|
|
41
|
+
## Semantic search (pgvector)
|
|
42
|
+
|
|
43
|
+
Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using pgvector (`<=>`, HNSW index):
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from langgraph_store_core import bedrock_titan_embeddings
|
|
47
|
+
from langgraph_store_postgres import PostgresStore
|
|
48
|
+
|
|
49
|
+
store = PostgresStore(url, table_name="memory",
|
|
50
|
+
index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
|
|
51
|
+
store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
|
|
52
|
+
hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
|
|
53
|
+
print(hits[0].score, hits[0].value)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
|
|
57
|
+
|
|
58
|
+
Requires the [pgvector](https://github.com/pgvector/pgvector) extension on the server (`apt install postgresql-16-pgvector`, or built in on RDS / Cloud SQL / Azure Database). The store runs `CREATE EXTENSION IF NOT EXISTS vector`, adds an `embedding vector(dims)` column and an HNSW cosine index. Namespace prefix and plain-equality filters run inside the SQL query (JSONB containment); operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
|
|
59
|
+
|
|
60
|
+
## Data model
|
|
61
|
+
|
|
62
|
+
A single table `(prefix, key, value JSONB, created_at, updated_at[, embedding vector])` with primary key `(prefix, key)`, created automatically. Namespace tuples are joined with a unit separator into `prefix`; search is a prefix scan, filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
|
|
63
|
+
|
|
64
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# langgraph-store-postgres
|
|
2
|
+
|
|
3
|
+
A **PostgreSQL** long-term-memory store (`BaseStore`) for [LangGraph](https://langchain-ai.github.io/langgraph/) — namespaced key/value memory with prefix search, filters, `list_namespaces`, and **native semantic search via pgvector**.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install langgraph-store-postgres
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from langgraph_store_postgres import PostgresStore
|
|
11
|
+
|
|
12
|
+
store = PostgresStore("postgresql://user:pass@localhost:5432/db") # or engine=<Engine>
|
|
13
|
+
|
|
14
|
+
store.put(("users", "1", "memories"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
15
|
+
item = store.get(("users", "1", "memories"), "food")
|
|
16
|
+
hits = store.search(("users", "1"), filter={"kind": "pref"}, limit=10)
|
|
17
|
+
spaces = store.list_namespaces(prefix=("users",))
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use it as a LangGraph store: `graph.compile(store=PostgresStore(...))`. Async methods (`aget`/`aput`/`asearch`/…) work too — the sync calls run in a thread.
|
|
21
|
+
|
|
22
|
+
## Semantic search (pgvector)
|
|
23
|
+
|
|
24
|
+
Pass a LangGraph `IndexConfig` and the store embeds the configured fields on `put` and ranks `search(query=...)` by cosine similarity using pgvector (`<=>`, HNSW index):
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from langgraph_store_core import bedrock_titan_embeddings
|
|
28
|
+
from langgraph_store_postgres import PostgresStore
|
|
29
|
+
|
|
30
|
+
store = PostgresStore(url, table_name="memory",
|
|
31
|
+
index={"dims": 1024, "embed": bedrock_titan_embeddings(dimensions=1024), "fields": ["text"]})
|
|
32
|
+
store.put(("memories", "kamal"), "k1", {"text": "the user loves sushi", "kind": "pref"})
|
|
33
|
+
hits = store.search(("memories", "kamal"), query="what food does the user like?", filter={"kind": "pref"})
|
|
34
|
+
print(hits[0].score, hits[0].value)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`embed` may be any LangChain `Embeddings`, a `list[str] -> list[list[float]]` callable, or a provider string. `fields` defaults to `["$"]` (whole value as JSON). `put(..., index=False)` skips embedding for one item.
|
|
38
|
+
|
|
39
|
+
Requires the [pgvector](https://github.com/pgvector/pgvector) extension on the server (`apt install postgresql-16-pgvector`, or built in on RDS / Cloud SQL / Azure Database). The store runs `CREATE EXTENSION IF NOT EXISTS vector`, adds an `embedding vector(dims)` column and an HNSW cosine index. Namespace prefix and plain-equality filters run inside the SQL query (JSONB containment); operator filters (`$gt`, `$in`, …) are applied on the returned candidates.
|
|
40
|
+
|
|
41
|
+
## Data model
|
|
42
|
+
|
|
43
|
+
A single table `(prefix, key, value JSONB, created_at, updated_at[, embedding vector])` with primary key `(prefix, key)`, created automatically. Namespace tuples are joined with a unit separator into `prefix`; search is a prefix scan, filters and namespace matching are evaluated in the [core](https://pypi.org/project/langgraph-store-core/).
|
|
44
|
+
|
|
45
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langgraph-store-postgres"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PostgreSQL long-term-memory store (BaseStore) for LangGraph — namespaced key/value memory with filters, list_namespaces and native semantic (pgvector) search"
|
|
9
|
+
authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"langgraph-store-core>=0.1.0",
|
|
14
|
+
"sqlalchemy>=2.0",
|
|
15
|
+
"psycopg2-binary",
|
|
16
|
+
"pgvector>=0.2",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["langgraph", "store", "basestore", "postgres", "postgresql", "long-term-memory", "agent-memory"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/skamalj/langgraph-store"
|
|
28
|
+
Repository = "https://github.com/skamalj/langgraph-store.git"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=7.0", "pytest-asyncio"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/langgraph_store_postgres"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""PostgreSQL implementation of the LangGraph long-term-memory ``BaseStore``.
|
|
2
|
+
|
|
3
|
+
``PostgresStore`` persists namespaced key/value memory in a single table and
|
|
4
|
+
supports get / put / delete / search (namespace-prefix + filter) and
|
|
5
|
+
``list_namespaces``. With an ``IndexConfig`` it also does **semantic search**
|
|
6
|
+
natively via `pgvector <https://github.com/pgvector/pgvector>`_ (cosine
|
|
7
|
+
distance, HNSW index). Built on ``langgraph-store-core``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import (
|
|
15
|
+
Column,
|
|
16
|
+
Index,
|
|
17
|
+
MetaData,
|
|
18
|
+
String,
|
|
19
|
+
Table,
|
|
20
|
+
create_engine,
|
|
21
|
+
delete as sa_delete,
|
|
22
|
+
select,
|
|
23
|
+
text,
|
|
24
|
+
)
|
|
25
|
+
from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
|
|
26
|
+
from sqlalchemy.engine import Engine
|
|
27
|
+
|
|
28
|
+
from langgraph_store_core import IndexConfig, KVStore
|
|
29
|
+
|
|
30
|
+
__all__ = ["PostgresStore"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _escape_like(value: str) -> str:
|
|
34
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PostgresStore(KVStore):
|
|
38
|
+
"""LangGraph ``BaseStore`` backed by PostgreSQL.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```python
|
|
42
|
+
from langgraph_store_postgres import PostgresStore
|
|
43
|
+
|
|
44
|
+
store = PostgresStore("postgresql://user:pass@localhost:5432/db")
|
|
45
|
+
store.put(("users", "1"), "profile", {"name": "Kamal"})
|
|
46
|
+
item = store.get(("users", "1"), "profile")
|
|
47
|
+
|
|
48
|
+
# semantic search (needs the pgvector extension on the server)
|
|
49
|
+
store = PostgresStore(url, index={"dims": 1024, "embed": my_embedder, "fields": ["text"]})
|
|
50
|
+
hits = store.search(("users", "1"), query="what does the user like?")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
When ``index`` is given the table gains an ``embedding vector(dims)`` column
|
|
54
|
+
and an HNSW cosine index; ``CREATE EXTENSION IF NOT EXISTS vector`` is run
|
|
55
|
+
(requires the extension to be installed on the server).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
supports_native_vector_search = True
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
url: Optional[str] = None,
|
|
63
|
+
*,
|
|
64
|
+
table_name: str = "langgraph_store",
|
|
65
|
+
engine: Optional[Engine] = None,
|
|
66
|
+
index: Optional[IndexConfig] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(index=index)
|
|
69
|
+
if engine is None and url is None:
|
|
70
|
+
raise ValueError("Provide either 'url' or 'engine'")
|
|
71
|
+
self._engine = engine or create_engine(url) # type: ignore[arg-type]
|
|
72
|
+
self._metadata = MetaData()
|
|
73
|
+
columns = [
|
|
74
|
+
Column("prefix", String, primary_key=True),
|
|
75
|
+
Column("key", String, primary_key=True),
|
|
76
|
+
Column("value", JSONB, nullable=False),
|
|
77
|
+
Column("created_at", String, nullable=False),
|
|
78
|
+
Column("updated_at", String, nullable=False),
|
|
79
|
+
]
|
|
80
|
+
if self.dims:
|
|
81
|
+
from pgvector.sqlalchemy import Vector # optional dependency
|
|
82
|
+
|
|
83
|
+
columns.append(Column("embedding", Vector(self.dims), nullable=True))
|
|
84
|
+
with self._engine.begin() as conn:
|
|
85
|
+
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
|
86
|
+
self._table = Table(table_name, self._metadata, *columns)
|
|
87
|
+
if self.dims:
|
|
88
|
+
Index(
|
|
89
|
+
f"{table_name}_embedding_hnsw",
|
|
90
|
+
self._table.c.embedding,
|
|
91
|
+
postgresql_using="hnsw",
|
|
92
|
+
postgresql_with={"m": 16, "ef_construction": 64},
|
|
93
|
+
postgresql_ops={"embedding": "vector_cosine_ops"},
|
|
94
|
+
)
|
|
95
|
+
self._metadata.create_all(self._engine)
|
|
96
|
+
|
|
97
|
+
def _row(self, r) -> dict:
|
|
98
|
+
emb = getattr(r, "embedding", None) if self.dims else None
|
|
99
|
+
return {
|
|
100
|
+
"prefix": r.prefix, "key": r.key, "value": r.value,
|
|
101
|
+
"created_at": r.created_at, "updated_at": r.updated_at,
|
|
102
|
+
"embedding": list(emb) if emb is not None else None,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def _read(self, prefix: str, key: str) -> Optional[dict]:
|
|
106
|
+
t = self._table
|
|
107
|
+
with self._engine.connect() as conn:
|
|
108
|
+
r = conn.execute(
|
|
109
|
+
select(t).where(t.c.prefix == prefix, t.c.key == key)
|
|
110
|
+
).first()
|
|
111
|
+
return self._row(r) if r else None
|
|
112
|
+
|
|
113
|
+
def _write(self, row: dict) -> None:
|
|
114
|
+
t = self._table
|
|
115
|
+
values = {k: row[k] for k in ("prefix", "key", "value", "created_at", "updated_at")}
|
|
116
|
+
if self.dims:
|
|
117
|
+
values["embedding"] = row.get("embedding")
|
|
118
|
+
stmt = pg_insert(t).values(**values).on_conflict_do_update(
|
|
119
|
+
index_elements=[t.c.prefix, t.c.key],
|
|
120
|
+
set_={k: v for k, v in values.items() if k not in ("prefix", "key")},
|
|
121
|
+
)
|
|
122
|
+
with self._engine.begin() as conn:
|
|
123
|
+
conn.execute(stmt)
|
|
124
|
+
|
|
125
|
+
def _remove(self, prefix: str, key: str) -> None:
|
|
126
|
+
t = self._table
|
|
127
|
+
with self._engine.begin() as conn:
|
|
128
|
+
conn.execute(sa_delete(t).where(t.c.prefix == prefix, t.c.key == key))
|
|
129
|
+
|
|
130
|
+
def _prefix_clause(self, prefix: str):
|
|
131
|
+
t = self._table
|
|
132
|
+
if prefix == "":
|
|
133
|
+
return None
|
|
134
|
+
return t.c.prefix.like(f"{_escape_like(prefix)}%", escape="\\")
|
|
135
|
+
|
|
136
|
+
def _scan(self, prefix: str) -> List[dict]:
|
|
137
|
+
t = self._table
|
|
138
|
+
stmt = select(t)
|
|
139
|
+
clause = self._prefix_clause(prefix)
|
|
140
|
+
if clause is not None:
|
|
141
|
+
stmt = stmt.where(clause)
|
|
142
|
+
with self._engine.connect() as conn:
|
|
143
|
+
rows = conn.execute(stmt).all()
|
|
144
|
+
return [self._row(r) for r in rows]
|
|
145
|
+
|
|
146
|
+
def _vector_search(
|
|
147
|
+
self, prefix: str, vector: List[float], filter: Optional[dict], limit: int
|
|
148
|
+
) -> List[Tuple[dict, float]]:
|
|
149
|
+
"""Native pgvector ANN: cosine distance ordered, prefix + equality filters in SQL."""
|
|
150
|
+
t = self._table
|
|
151
|
+
distance = t.c.embedding.cosine_distance(vector).label("distance")
|
|
152
|
+
stmt = select(t, distance).where(t.c.embedding.isnot(None))
|
|
153
|
+
clause = self._prefix_clause(prefix)
|
|
154
|
+
if clause is not None:
|
|
155
|
+
stmt = stmt.where(clause)
|
|
156
|
+
# Push plain-equality filters into SQL as JSONB containment (type-safe);
|
|
157
|
+
# operator filters ($gt, $in, ...) are applied by core on the returned rows.
|
|
158
|
+
equality = {f: v for f, v in (filter or {}).items() if not isinstance(v, dict)}
|
|
159
|
+
if equality:
|
|
160
|
+
stmt = stmt.where(t.c.value.contains(equality))
|
|
161
|
+
stmt = stmt.order_by(distance).limit(limit)
|
|
162
|
+
with self._engine.connect() as conn:
|
|
163
|
+
rows = conn.execute(stmt).all()
|
|
164
|
+
return [(self._row(r), 1.0 - float(r.distance)) for r in rows]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Semantic-search tests for PostgresStore (native pgvector path) against a real PostgreSQL with pgvector."""
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import create_engine, text
|
|
7
|
+
|
|
8
|
+
from langgraph_store_core.testing import FakeEmbeddings
|
|
9
|
+
from langgraph_store_postgres import PostgresStore
|
|
10
|
+
|
|
11
|
+
URL = os.environ.get("SQL_TEST_URL", "postgresql+psycopg2://postgres:postgres@localhost:5433/postgres")
|
|
12
|
+
DIMS = 64
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pgvector_available() -> bool:
|
|
16
|
+
try:
|
|
17
|
+
with create_engine(URL).begin() as c:
|
|
18
|
+
c.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
pytestmark = pytest.mark.skipif(not _pgvector_available(), reason=f"PostgreSQL+pgvector not reachable: {URL}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture()
|
|
28
|
+
def store():
|
|
29
|
+
tname = f"lg_vstore_test_{uuid.uuid4().hex[:8]}"
|
|
30
|
+
s = PostgresStore(url=URL, table_name=tname, index={"dims": DIMS, "embed": FakeEmbeddings(DIMS), "fields": ["text"]})
|
|
31
|
+
yield s
|
|
32
|
+
with s._engine.begin() as c:
|
|
33
|
+
c.execute(text(f'DROP TABLE IF EXISTS "{tname}"'))
|
|
34
|
+
s._engine.dispose()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _seed(s):
|
|
38
|
+
s.put(("memories", "kamal"), "food", {"text": "the user loves sushi and japanese food", "kind": "pref"})
|
|
39
|
+
s.put(("memories", "kamal"), "lang", {"text": "the user writes python every day", "kind": "fact"})
|
|
40
|
+
s.put(("memories", "kamal"), "city", {"text": "the user lives by the sea in a big city", "kind": "fact"})
|
|
41
|
+
s.put(("memories", "priya"), "food", {"text": "loves sushi", "kind": "pref"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_table_has_vector_column_and_index(store):
|
|
45
|
+
with store._engine.connect() as c:
|
|
46
|
+
cols = {r[0]: r[1] for r in c.execute(text(
|
|
47
|
+
f"select column_name, udt_name from information_schema.columns where table_name='{store._table.name}'"))}
|
|
48
|
+
assert cols.get("embedding") == "vector"
|
|
49
|
+
idx = c.execute(text(f"select indexdef from pg_indexes where tablename='{store._table.name}'")).all()
|
|
50
|
+
assert any("hnsw" in r[0] for r in idx)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_put_embeds_and_index_false_skips(store):
|
|
54
|
+
store.put(("n",), "k", {"text": "hello world"})
|
|
55
|
+
assert len(store._read("n", "k")["embedding"]) == DIMS
|
|
56
|
+
store.put(("n",), "k2", {"text": "hello"}, index=False)
|
|
57
|
+
assert store._read("n", "k2")["embedding"] is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_query_ranks_and_scores(store):
|
|
61
|
+
_seed(store)
|
|
62
|
+
hits = store.search(("memories", "kamal"), query="sushi and japanese food")
|
|
63
|
+
assert hits[0].key == "food" and hits[0].score is not None
|
|
64
|
+
assert hits[0].score >= hits[-1].score
|
|
65
|
+
assert all(h.namespace == ("memories", "kamal") for h in hits)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_query_prefix_children_and_filter(store):
|
|
69
|
+
_seed(store)
|
|
70
|
+
keys = {(h.namespace, h.key) for h in store.search(("memories",), query="sushi")}
|
|
71
|
+
assert (("memories", "kamal"), "food") in keys and (("memories", "priya"), "food") in keys
|
|
72
|
+
facts = store.search(("memories", "kamal"), query="user", filter={"kind": "fact"})
|
|
73
|
+
assert facts and all(h.value["kind"] == "fact" for h in facts)
|
|
74
|
+
gt = store.search(("memories", "kamal"), query="user", filter={"kind": {"$in": ["pref"]}})
|
|
75
|
+
assert [h.key for h in gt] == ["food"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_query_limit_offset(store):
|
|
79
|
+
_seed(store)
|
|
80
|
+
first = store.search(("memories", "kamal"), query="user", limit=2)
|
|
81
|
+
rest = store.search(("memories", "kamal"), query="user", limit=2, offset=2)
|
|
82
|
+
assert len(first) == 2 and len(rest) == 1 and not {h.key for h in first} & {h.key for h in rest}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_no_query_is_filter_only(store):
|
|
86
|
+
_seed(store)
|
|
87
|
+
hits = store.search(("memories", "kamal"))
|
|
88
|
+
assert [h.key for h in hits] == ["city", "food", "lang"] and all(h.score is None for h in hits)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_native_path_used(store, monkeypatch):
|
|
92
|
+
_seed(store)
|
|
93
|
+
called = []
|
|
94
|
+
orig = store._vector_search
|
|
95
|
+
monkeypatch.setattr(store, "_vector_search", lambda *a, **k: (called.append(1), orig(*a, **k))[1])
|
|
96
|
+
store.search(("memories",), query="sushi")
|
|
97
|
+
assert called
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_async_query(store):
|
|
101
|
+
_seed(store)
|
|
102
|
+
hits = await store.asearch(("memories", "kamal"), query="sushi")
|
|
103
|
+
assert hits[0].key == "food"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tests for PostgresStore (LangGraph BaseStore) against real local PostgreSQL."""
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import create_engine, text
|
|
7
|
+
|
|
8
|
+
from langgraph_store_postgres import PostgresStore
|
|
9
|
+
|
|
10
|
+
URL = os.environ.get(
|
|
11
|
+
"SQL_TEST_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/postgres"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _db_available() -> bool:
|
|
16
|
+
try:
|
|
17
|
+
with create_engine(URL).connect() as c:
|
|
18
|
+
c.execute(text("select 1"))
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
pytestmark = pytest.mark.skipif(not _db_available(), reason=f"PostgreSQL not reachable: {URL}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture()
|
|
28
|
+
def store():
|
|
29
|
+
tname = f"lg_store_test_{uuid.uuid4().hex[:8]}"
|
|
30
|
+
s = PostgresStore(url=URL, table_name=tname)
|
|
31
|
+
yield s
|
|
32
|
+
with s._engine.begin() as c:
|
|
33
|
+
c.execute(text(f'DROP TABLE IF EXISTS "{tname}"'))
|
|
34
|
+
s._engine.dispose()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_put_get(store):
|
|
38
|
+
store.put(("users", "1"), "food", {"text": "sushi"})
|
|
39
|
+
it = store.get(("users", "1"), "food")
|
|
40
|
+
assert it is not None
|
|
41
|
+
assert it.value == {"text": "sushi"}
|
|
42
|
+
assert it.namespace == ("users", "1")
|
|
43
|
+
assert it.key == "food"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_get_missing_is_none(store):
|
|
47
|
+
assert store.get(("nope",), "x") is None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_update_preserves_created_at(store):
|
|
51
|
+
store.put(("u",), "k", {"v": 1})
|
|
52
|
+
a = store.get(("u",), "k")
|
|
53
|
+
store.put(("u",), "k", {"v": 2})
|
|
54
|
+
b = store.get(("u",), "k")
|
|
55
|
+
assert b.value == {"v": 2}
|
|
56
|
+
assert b.created_at == a.created_at # created_at preserved across updates
|
|
57
|
+
assert b.updated_at >= a.updated_at
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_delete_both_ways(store):
|
|
61
|
+
store.put(("u",), "k", {"v": 1})
|
|
62
|
+
store.delete(("u",), "k")
|
|
63
|
+
assert store.get(("u",), "k") is None
|
|
64
|
+
store.put(("u",), "k2", {"v": 1})
|
|
65
|
+
store.put(("u",), "k2", None) # put None == delete
|
|
66
|
+
assert store.get(("u",), "k2") is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_search_prefix_and_filter(store):
|
|
70
|
+
store.put(("users", "1", "mem"), "a", {"kind": "pref"})
|
|
71
|
+
store.put(("users", "1", "mem"), "b", {"kind": "fact"})
|
|
72
|
+
store.put(("users", "2", "mem"), "c", {"kind": "pref"})
|
|
73
|
+
assert sorted(i.key for i in store.search(("users", "1"))) == ["a", "b"]
|
|
74
|
+
assert [i.key for i in store.search(("users", "1"), filter={"kind": "pref"})] == ["a"]
|
|
75
|
+
assert store.search(("users", "1"))[0].namespace == ("users", "1", "mem")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_search_operator_filter(store):
|
|
79
|
+
store.put(("n",), "a", {"score": 5})
|
|
80
|
+
store.put(("n",), "b", {"score": 15})
|
|
81
|
+
hits = store.search(("n",), filter={"score": {"$gt": 10}})
|
|
82
|
+
assert [i.key for i in hits] == ["b"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_search_limit_offset(store):
|
|
86
|
+
for i in range(5):
|
|
87
|
+
store.put(("n",), f"k{i}", {"i": i})
|
|
88
|
+
assert [i.key for i in store.search(("n",), limit=2)] == ["k0", "k1"]
|
|
89
|
+
assert [i.key for i in store.search(("n",), limit=2, offset=2)] == ["k2", "k3"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_list_namespaces(store):
|
|
93
|
+
store.put(("users", "1", "mem"), "a", {})
|
|
94
|
+
store.put(("users", "2", "mem"), "b", {})
|
|
95
|
+
store.put(("orgs", "x"), "c", {})
|
|
96
|
+
allns = store.list_namespaces()
|
|
97
|
+
assert ("users", "1", "mem") in allns and ("orgs", "x") in allns
|
|
98
|
+
assert all(n[0] == "users" for n in store.list_namespaces(prefix=("users",)))
|
|
99
|
+
assert all(n[-1] == "mem" for n in store.list_namespaces(suffix=("mem",)))
|
|
100
|
+
depth1 = store.list_namespaces(max_depth=1)
|
|
101
|
+
assert ("users",) in depth1 and ("orgs",) in depth1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_list_namespaces_wildcard(store):
|
|
105
|
+
store.put(("a", "1", "x"), "k", {})
|
|
106
|
+
store.put(("a", "2", "x"), "k", {})
|
|
107
|
+
store.put(("a", "1", "y"), "k", {})
|
|
108
|
+
hits = store.list_namespaces(prefix=("a", "*", "x"))
|
|
109
|
+
assert set(hits) == {("a", "1", "x"), ("a", "2", "x")}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def test_async_surface(store):
|
|
113
|
+
await store.aput(("u",), "k", {"v": 1})
|
|
114
|
+
it = await store.aget(("u",), "k")
|
|
115
|
+
assert it.value == {"v": 1}
|
|
116
|
+
res = await store.asearch(("u",))
|
|
117
|
+
assert [i.key for i in res] == ["k"]
|
|
118
|
+
await store.adelete(("u",), "k")
|
|
119
|
+
assert await store.aget(("u",), "k") is None
|