langchain-ai--langgraph
a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
1432 行
54 KiB
Python
1432 行
54 KiB
Python
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import datetime
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
from collections import defaultdict
|
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
from contextlib import contextmanager
|
|
from typing import Any, Literal, NamedTuple, cast
|
|
|
|
import orjson
|
|
import sqlite_vec # type: ignore[import-untyped]
|
|
from langgraph.store.base import (
|
|
BaseStore,
|
|
GetOp,
|
|
IndexConfig,
|
|
Item,
|
|
ListNamespacesOp,
|
|
Op,
|
|
PutOp,
|
|
Result,
|
|
SearchItem,
|
|
SearchOp,
|
|
TTLConfig,
|
|
ensure_embeddings,
|
|
get_text_at_path,
|
|
tokenize_path,
|
|
)
|
|
|
|
_AIO_ERROR_MSG = (
|
|
"The SqliteStore does not support async methods. "
|
|
"Consider using AsyncSqliteStore instead.\n"
|
|
"from langgraph.store.sqlite.aio import AsyncSqliteStore\n"
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MIGRATIONS = [
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS store (
|
|
-- 'prefix' represents the doc's 'namespace'
|
|
prefix text NOT NULL,
|
|
key text NOT NULL,
|
|
value text NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (prefix, key)
|
|
);
|
|
""",
|
|
"""
|
|
-- For faster lookups by prefix
|
|
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store (prefix);
|
|
""",
|
|
"""
|
|
-- Add expires_at column to store table
|
|
ALTER TABLE store
|
|
ADD COLUMN expires_at TIMESTAMP;
|
|
""",
|
|
"""
|
|
-- Add ttl_minutes column to store table
|
|
ALTER TABLE store
|
|
ADD COLUMN ttl_minutes REAL;
|
|
""",
|
|
"""
|
|
-- Add index for efficient TTL sweeping
|
|
CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at)
|
|
WHERE expires_at IS NOT NULL;
|
|
""",
|
|
]
|
|
|
|
VECTOR_MIGRATIONS = [
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS store_vectors (
|
|
prefix text NOT NULL,
|
|
key text NOT NULL,
|
|
field_name text NOT NULL,
|
|
embedding BLOB,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (prefix, key, field_name),
|
|
FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE
|
|
);
|
|
""",
|
|
]
|
|
|
|
|
|
class SqliteIndexConfig(IndexConfig):
|
|
"""Configuration for vector embeddings in SQLite store."""
|
|
|
|
pass
|
|
|
|
|
|
def _namespace_to_text(
|
|
namespace: tuple[str, ...], handle_wildcards: bool = False
|
|
) -> str:
|
|
"""Convert namespace tuple to text string."""
|
|
if handle_wildcards:
|
|
namespace = tuple("%" if val == "*" else val for val in namespace)
|
|
return ".".join(namespace)
|
|
|
|
|
|
def _decode_ns_text(namespace: str) -> tuple[str, ...]:
|
|
"""Convert namespace string to tuple."""
|
|
return tuple(namespace.split("."))
|
|
|
|
|
|
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
|
|
|
|
|
def _validate_filter_key(key: str) -> None:
|
|
"""Validate that a filter key is safe for use in SQL queries.
|
|
|
|
Args:
|
|
key: The filter key to validate
|
|
|
|
Raises:
|
|
ValueError: If the key contains invalid characters that could enable SQL injection
|
|
"""
|
|
# Allow alphanumeric characters, underscores, dots, and hyphens
|
|
# This covers typical JSON property names while preventing SQL injection
|
|
if not _FILTER_PATTERN.match(key):
|
|
raise ValueError(
|
|
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
|
|
)
|
|
|
|
|
|
def _json_loads(content: bytes | str | orjson.Fragment) -> Any:
|
|
if isinstance(content, orjson.Fragment):
|
|
if hasattr(content, "buf"):
|
|
content = content.buf
|
|
else:
|
|
if isinstance(content.contents, bytes):
|
|
content = content.contents
|
|
else:
|
|
content = content.contents.encode()
|
|
return orjson.loads(cast(bytes, content))
|
|
elif isinstance(content, bytes):
|
|
return orjson.loads(content)
|
|
else:
|
|
return orjson.loads(content)
|
|
|
|
|
|
def _row_to_item(
|
|
namespace: tuple[str, ...],
|
|
row: dict[str, Any],
|
|
*,
|
|
loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
|
|
) -> Item:
|
|
"""Convert a row from the database into an Item."""
|
|
val = row["value"]
|
|
if not isinstance(val, dict):
|
|
val = (loader or _json_loads)(val)
|
|
|
|
kwargs = {
|
|
"key": row["key"],
|
|
"namespace": namespace,
|
|
"value": val,
|
|
"created_at": row["created_at"],
|
|
"updated_at": row["updated_at"],
|
|
}
|
|
|
|
return Item(**kwargs)
|
|
|
|
|
|
def _row_to_search_item(
|
|
namespace: tuple[str, ...],
|
|
row: dict[str, Any],
|
|
*,
|
|
loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
|
|
) -> SearchItem:
|
|
"""Convert a row from the database into a SearchItem."""
|
|
loader = loader or _json_loads
|
|
val = row["value"]
|
|
score = row.get("score")
|
|
if score is not None:
|
|
try:
|
|
score = float(score)
|
|
except ValueError:
|
|
logger.warning("Invalid score: %s", score)
|
|
score = None
|
|
return SearchItem(
|
|
value=val if isinstance(val, dict) else loader(val),
|
|
key=row["key"],
|
|
namespace=namespace,
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
score=score,
|
|
)
|
|
|
|
|
|
def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
|
|
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
|
|
tot = 0
|
|
for idx, op in enumerate(ops):
|
|
grouped_ops[type(op)].append((idx, op))
|
|
tot += 1
|
|
return grouped_ops, tot
|
|
|
|
|
|
class PreparedGetQuery(NamedTuple):
|
|
query: str # Main query to execute
|
|
params: tuple # Parameters for the main query
|
|
namespace: tuple[str, ...] # Namespace info
|
|
items: list # List of items this query is for
|
|
kind: Literal["get", "refresh"]
|
|
|
|
|
|
class BaseSqliteStore:
|
|
"""Shared base class for SQLite stores."""
|
|
|
|
MIGRATIONS = MIGRATIONS
|
|
VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
|
|
supports_ttl = True
|
|
index_config: SqliteIndexConfig | None = None
|
|
ttl_config: TTLConfig | None = None
|
|
|
|
def _get_batch_GET_ops_queries(
|
|
self, get_ops: Sequence[tuple[int, GetOp]]
|
|
) -> list[PreparedGetQuery]:
|
|
"""
|
|
Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
|
|
|
|
Returns a list of PreparedGetQuery objects, which may include:
|
|
- Queries with kind='refresh' for TTL refresh operations
|
|
- Queries with kind='get' for data retrieval operations
|
|
"""
|
|
namespace_groups = defaultdict(list)
|
|
refresh_ttls = defaultdict(list)
|
|
for idx, op in get_ops:
|
|
namespace_groups[op.namespace].append((idx, op.key))
|
|
refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False))
|
|
|
|
results = []
|
|
for namespace, items in namespace_groups.items():
|
|
_, keys = zip(*items, strict=False)
|
|
this_refresh_ttls = refresh_ttls[namespace]
|
|
refresh_ttl_any = any(this_refresh_ttls)
|
|
|
|
# Always add the main query to get the data
|
|
select_query = f"""
|
|
SELECT key, value, created_at, updated_at, expires_at, ttl_minutes
|
|
FROM store
|
|
WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))})
|
|
"""
|
|
select_params = (_namespace_to_text(namespace), *keys)
|
|
results.append(
|
|
PreparedGetQuery(select_query, select_params, namespace, items, "get")
|
|
)
|
|
|
|
# Add a TTL refresh query if needed
|
|
if (
|
|
refresh_ttl_any
|
|
and self.ttl_config
|
|
and self.ttl_config.get("refresh_on_read", False)
|
|
):
|
|
placeholders = ",".join(["?"] * len(keys))
|
|
update_query = f"""
|
|
UPDATE store
|
|
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
|
|
WHERE prefix = ?
|
|
AND key IN ({placeholders})
|
|
AND ttl_minutes IS NOT NULL
|
|
"""
|
|
update_params = (_namespace_to_text(namespace), *keys)
|
|
results.append(
|
|
PreparedGetQuery(
|
|
update_query, update_params, namespace, items, "refresh"
|
|
)
|
|
)
|
|
|
|
return results
|
|
|
|
def _prepare_batch_PUT_queries(
|
|
self, put_ops: Sequence[tuple[int, PutOp]]
|
|
) -> tuple[
|
|
list[tuple[str, Sequence]],
|
|
tuple[str, Sequence[tuple[str, str, str, str]]] | None,
|
|
]:
|
|
# Last-write wins
|
|
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
|
|
for _, op in put_ops:
|
|
dedupped_ops[(op.namespace, op.key)] = op
|
|
|
|
inserts: list[PutOp] = []
|
|
deletes: list[PutOp] = []
|
|
for op in dedupped_ops.values():
|
|
if op.value is None:
|
|
deletes.append(op)
|
|
else:
|
|
inserts.append(op)
|
|
|
|
queries: list[tuple[str, Sequence]] = []
|
|
|
|
if deletes:
|
|
namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
|
|
for op in deletes:
|
|
namespace_groups[op.namespace].append(op.key)
|
|
for namespace, keys in namespace_groups.items():
|
|
placeholders = ",".join(["?" for _ in keys])
|
|
query = (
|
|
f"DELETE FROM store WHERE prefix = ? AND key IN ({placeholders})"
|
|
)
|
|
params = (_namespace_to_text(namespace), *keys)
|
|
queries.append((query, params))
|
|
|
|
embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
|
|
if inserts:
|
|
values = []
|
|
insertion_params = []
|
|
vector_values = []
|
|
embedding_request_params = []
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
|
|
# First handle main store insertions
|
|
for op in inserts:
|
|
if op.ttl is None:
|
|
expires_at = None
|
|
else:
|
|
expires_at = now + datetime.timedelta(minutes=op.ttl)
|
|
values.append("(?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)")
|
|
insertion_params.extend(
|
|
[
|
|
_namespace_to_text(op.namespace),
|
|
op.key,
|
|
orjson.dumps(cast(dict, op.value)),
|
|
expires_at,
|
|
op.ttl,
|
|
]
|
|
)
|
|
|
|
# Then handle embeddings if configured
|
|
if self.index_config:
|
|
for op in inserts:
|
|
if op.index is False:
|
|
continue
|
|
value = op.value
|
|
ns = _namespace_to_text(op.namespace)
|
|
k = op.key
|
|
|
|
if op.index is None:
|
|
paths = self.index_config["__tokenized_fields"]
|
|
else:
|
|
paths = [(ix, tokenize_path(ix)) for ix in op.index]
|
|
|
|
for path, tokenized_path in paths:
|
|
texts = get_text_at_path(value, tokenized_path)
|
|
for i, text in enumerate(texts):
|
|
pathname = f"{path}.{i}" if len(texts) > 1 else path
|
|
vector_values.append(
|
|
"(?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
|
)
|
|
embedding_request_params.append((ns, k, pathname, text))
|
|
|
|
values_str = ",".join(values)
|
|
query = f"""
|
|
INSERT OR REPLACE INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
|
|
VALUES {values_str}
|
|
"""
|
|
queries.append((query, insertion_params))
|
|
|
|
if vector_values:
|
|
values_str = ",".join(vector_values)
|
|
query = f"""
|
|
INSERT OR REPLACE INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at)
|
|
VALUES {values_str}
|
|
"""
|
|
embedding_request = (query, embedding_request_params)
|
|
|
|
return queries, embedding_request
|
|
|
|
def _prepare_batch_search_queries(
|
|
self, search_ops: Sequence[tuple[int, SearchOp]]
|
|
) -> tuple[
|
|
list[
|
|
tuple[str, list[None | str | list[float]], bool]
|
|
], # queries, params, needs_refresh
|
|
list[tuple[int, str]], # idx, query_text pairs to embed
|
|
]:
|
|
"""
|
|
Build per-SearchOp SQL queries (with optional TTL refresh flag) plus embedding requests.
|
|
Returns:
|
|
- queries: list of (SQL, param_list, needs_ttl_refresh_flag)
|
|
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
|
"""
|
|
queries = []
|
|
embedding_requests = []
|
|
|
|
for idx, (_, op) in enumerate(search_ops):
|
|
# Build filter conditions first
|
|
filter_params = []
|
|
filter_conditions = []
|
|
if op.filter:
|
|
for key, value in op.filter.items():
|
|
_validate_filter_key(key)
|
|
|
|
if isinstance(value, dict):
|
|
for op_name, val in value.items():
|
|
condition, filter_params_ = self._get_filter_condition(
|
|
key, op_name, val
|
|
)
|
|
filter_conditions.append(condition)
|
|
filter_params.extend(filter_params_)
|
|
else:
|
|
# SQLite json_extract returns unquoted string values
|
|
if isinstance(value, str):
|
|
filter_conditions.append(
|
|
"json_extract(value, '$." + key + "') = ?"
|
|
)
|
|
filter_params.append(value)
|
|
elif value is None:
|
|
filter_conditions.append(
|
|
"json_extract(value, '$." + key + "') IS NULL"
|
|
)
|
|
elif isinstance(value, bool):
|
|
# SQLite JSON stores booleans as integers
|
|
filter_conditions.append(
|
|
"json_extract(value, '$."
|
|
+ key
|
|
+ "') = "
|
|
+ ("1" if value else "0")
|
|
)
|
|
elif isinstance(value, (int, float)):
|
|
# Use parameterized query to handle special floats and large integers
|
|
filter_conditions.append(
|
|
"json_extract(value, '$." + key + "') = ?"
|
|
)
|
|
filter_params.append(float(value))
|
|
else:
|
|
# Complex objects (list, dict, …) – compare JSON text
|
|
filter_conditions.append(
|
|
"json_extract(value, '$." + key + "') = ?"
|
|
)
|
|
# orjson.dumps returns bytes → decode to str so SQLite sees TEXT
|
|
filter_params.append(orjson.dumps(value).decode())
|
|
|
|
# Vector search branch
|
|
if op.query and self.index_config:
|
|
embedding_requests.append((idx, op.query))
|
|
|
|
# Choose the similarity function and score expression based on distance type
|
|
distance_type = self.index_config.get("distance_type", "cosine")
|
|
|
|
if distance_type == "cosine":
|
|
score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
|
|
elif distance_type == "l2":
|
|
score_expr = "vec_distance_L2(sv.embedding, ?)"
|
|
elif distance_type == "inner_product":
|
|
# For inner product, we want higher values to be better, so negate the result
|
|
# since inner product similarity is higher when vectors are more similar
|
|
score_expr = "-1 * vec_distance_L1(sv.embedding, ?)"
|
|
else:
|
|
# Default to cosine similarity
|
|
score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
|
|
|
|
filter_str = (
|
|
""
|
|
if not filter_conditions
|
|
else " AND " + " AND ".join(filter_conditions)
|
|
)
|
|
if op.namespace_prefix:
|
|
prefix_filter_str = f"WHERE s.prefix LIKE ? {filter_str} "
|
|
ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
|
|
else:
|
|
ns_args = ()
|
|
if filter_str:
|
|
prefix_filter_str = f"WHERE {filter_str[5:]} "
|
|
else:
|
|
prefix_filter_str = ""
|
|
|
|
# We use a CTE to compute scores, with a SQLite-compatible approach for distinct results
|
|
base_query = f"""
|
|
WITH scored AS (
|
|
SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, s.expires_at, s.ttl_minutes,
|
|
{score_expr} AS score
|
|
FROM store s
|
|
JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
|
|
{prefix_filter_str}
|
|
ORDER BY score DESC
|
|
LIMIT ?
|
|
),
|
|
ranked AS (
|
|
SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score,
|
|
ROW_NUMBER() OVER (PARTITION BY prefix, key ORDER BY score DESC) as rn
|
|
FROM scored
|
|
)
|
|
SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score
|
|
FROM ranked
|
|
WHERE rn = 1
|
|
ORDER BY score DESC
|
|
LIMIT ?
|
|
OFFSET ?
|
|
"""
|
|
params = [
|
|
_PLACEHOLDER, # Vector placeholder
|
|
*ns_args,
|
|
*filter_params,
|
|
op.limit * 2, # Expanded limit for better results
|
|
op.limit,
|
|
op.offset,
|
|
]
|
|
# Regular search branch (no vector search)
|
|
else:
|
|
base_query = """
|
|
SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
|
|
FROM store
|
|
WHERE prefix LIKE ?
|
|
"""
|
|
params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
|
|
|
if filter_conditions:
|
|
params.extend(filter_params)
|
|
base_query += " AND " + " AND ".join(filter_conditions)
|
|
|
|
base_query += " ORDER BY updated_at DESC"
|
|
base_query += " LIMIT ? OFFSET ?"
|
|
params.extend([op.limit, op.offset])
|
|
|
|
# Debug the query
|
|
logger.debug(f"Search query: {base_query}")
|
|
logger.debug(f"Search params: {params}")
|
|
|
|
# Determine if TTL refresh is needed
|
|
needs_ttl_refresh = bool(
|
|
op.refresh_ttl
|
|
and self.ttl_config
|
|
and self.ttl_config.get("refresh_on_read", False)
|
|
)
|
|
|
|
# The base_query is now the final_sql, and we pass the refresh flag
|
|
final_sql = base_query
|
|
final_params = params
|
|
|
|
queries.append((final_sql, final_params, needs_ttl_refresh))
|
|
|
|
return queries, embedding_requests
|
|
|
|
def _get_batch_list_namespaces_queries(
|
|
self,
|
|
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
) -> list[tuple[str, Sequence]]:
|
|
queries: list[tuple[str, Sequence]] = []
|
|
|
|
for _, op in list_ops:
|
|
where_clauses: list[str] = []
|
|
params: list[Any] = []
|
|
|
|
if op.match_conditions:
|
|
for cond in op.match_conditions:
|
|
if cond.match_type == "prefix":
|
|
where_clauses.append("prefix LIKE ?")
|
|
params.append(
|
|
f"{_namespace_to_text(cond.path, handle_wildcards=True)}%"
|
|
)
|
|
elif cond.match_type == "suffix":
|
|
where_clauses.append("prefix LIKE ?")
|
|
params.append(
|
|
f"%{_namespace_to_text(cond.path, handle_wildcards=True)}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Unknown match_type in list_namespaces: %s", cond.match_type
|
|
)
|
|
|
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
|
|
if op.max_depth is not None:
|
|
query = f"""
|
|
WITH RECURSIVE split(original, truncated, remainder, depth) AS (
|
|
SELECT
|
|
prefix AS original,
|
|
'' AS truncated,
|
|
prefix AS remainder,
|
|
0 AS depth
|
|
FROM (SELECT DISTINCT prefix FROM store {where_sql})
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
original,
|
|
CASE
|
|
WHEN depth = 0
|
|
THEN substr(remainder,
|
|
1,
|
|
CASE
|
|
WHEN instr(remainder, '.') > 0
|
|
THEN instr(remainder, '.') - 1
|
|
ELSE length(remainder)
|
|
END)
|
|
ELSE
|
|
truncated || '.' ||
|
|
substr(remainder,
|
|
1,
|
|
CASE
|
|
WHEN instr(remainder, '.') > 0
|
|
THEN instr(remainder, '.') - 1
|
|
ELSE length(remainder)
|
|
END)
|
|
END AS truncated,
|
|
CASE
|
|
WHEN instr(remainder, '.') > 0
|
|
THEN substr(remainder, instr(remainder, '.') + 1)
|
|
ELSE ''
|
|
END AS remainder,
|
|
depth + 1 AS depth
|
|
FROM split
|
|
WHERE remainder <> ''
|
|
AND depth < ?
|
|
)
|
|
SELECT DISTINCT truncated AS prefix
|
|
FROM split
|
|
WHERE depth = ? OR remainder = ''
|
|
ORDER BY prefix
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
params.extend([op.max_depth, op.max_depth, op.limit, op.offset])
|
|
|
|
else:
|
|
query = f"""
|
|
SELECT DISTINCT prefix
|
|
FROM store
|
|
{where_sql}
|
|
ORDER BY prefix
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
params.extend([op.limit, op.offset])
|
|
|
|
queries.append((query, tuple(params)))
|
|
|
|
return queries
|
|
|
|
def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
|
|
"""Helper to generate filter conditions."""
|
|
_validate_filter_key(key)
|
|
|
|
# We need to properly format values for SQLite JSON extraction comparison
|
|
if op == "$eq":
|
|
if isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') = ?", [value]
|
|
elif value is None:
|
|
return f"json_extract(value, '$.{key}') IS NULL", []
|
|
elif isinstance(value, bool):
|
|
# SQLite JSON stores booleans as integers
|
|
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
|
elif isinstance(value, (int, float)):
|
|
# Convert to float to handle inf, -inf, nan, and very large integers
|
|
# SQLite REAL can handle these cases better than INTEGER
|
|
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
|
elif op == "$gt":
|
|
# For numeric values, SQLite needs to compare as numbers, not strings
|
|
if isinstance(value, (int, float)):
|
|
# Convert to float to handle special values and very large integers
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') > ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
|
elif op == "$gte":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') >= ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
|
elif op == "$lt":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') < ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
|
elif op == "$lte":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') <= ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
|
elif op == "$ne":
|
|
if isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') != ?", [value]
|
|
elif value is None:
|
|
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
|
elif isinstance(value, bool):
|
|
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
|
elif isinstance(value, (int, float)):
|
|
# Convert to float for consistency
|
|
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
|
else:
|
|
raise ValueError(f"Unsupported operator: {op}")
|
|
|
|
|
|
class SqliteStore(BaseSqliteStore, BaseStore):
|
|
"""SQLite-backed store with optional vector search capabilities.
|
|
|
|
Examples:
|
|
Basic setup and usage:
|
|
```python
|
|
from langgraph.store.sqlite import SqliteStore
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(":memory:")
|
|
store = SqliteStore(conn)
|
|
store.setup() # Run migrations. Done once
|
|
|
|
# Store and retrieve data
|
|
store.put(("users", "123"), "prefs", {"theme": "dark"})
|
|
item = store.get(("users", "123"), "prefs")
|
|
```
|
|
|
|
Or using the convenient `from_conn_string` helper:
|
|
|
|
```python
|
|
from langgraph.store.sqlite import SqliteStore
|
|
|
|
with SqliteStore.from_conn_string(":memory:") as store:
|
|
store.setup()
|
|
|
|
# Store and retrieve data
|
|
store.put(("users", "123"), "prefs", {"theme": "dark"})
|
|
item = store.get(("users", "123"), "prefs")
|
|
```
|
|
|
|
Vector search using LangChain embeddings:
|
|
```python
|
|
from langchain.embeddings import OpenAIEmbeddings
|
|
from langgraph.store.sqlite import SqliteStore
|
|
|
|
with SqliteStore.from_conn_string(
|
|
":memory:",
|
|
index={
|
|
"dims": 1536,
|
|
"embed": OpenAIEmbeddings(),
|
|
"fields": ["text"] # specify which fields to embed
|
|
}
|
|
) as store:
|
|
store.setup() # Run migrations
|
|
|
|
# Store documents
|
|
store.put(("docs",), "doc1", {"text": "Python tutorial"})
|
|
store.put(("docs",), "doc2", {"text": "TypeScript guide"})
|
|
store.put(("docs",), "doc3", {"text": "Other guide"}, index=False) # don't index
|
|
|
|
# Search by similarity
|
|
results = store.search(("docs",), query="programming guides", limit=2)
|
|
```
|
|
|
|
Note:
|
|
Semantic search is disabled by default. You can enable it by providing an `index` configuration
|
|
when creating the store. Without this configuration, all `index` arguments passed to
|
|
`put` or `aput` will have no effect.
|
|
|
|
Warning:
|
|
Make sure to call `setup()` before first use to create necessary tables and indexes.
|
|
"""
|
|
|
|
MIGRATIONS = MIGRATIONS
|
|
VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
|
|
supports_ttl = True
|
|
|
|
def __init__(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
deserializer: (
|
|
Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None
|
|
) = None,
|
|
index: SqliteIndexConfig | None = None,
|
|
ttl: TTLConfig | None = None,
|
|
):
|
|
super().__init__()
|
|
self._deserializer = deserializer
|
|
self.conn = conn
|
|
self.lock = threading.Lock()
|
|
self.is_setup = False
|
|
self.index_config = index
|
|
if self.index_config:
|
|
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
|
|
else:
|
|
self.embeddings = None
|
|
self.ttl_config = ttl
|
|
self._ttl_sweeper_thread: threading.Thread | None = None
|
|
self._ttl_stop_event = threading.Event()
|
|
|
|
def _get_batch_GET_ops_queries(
|
|
self, get_ops: Sequence[tuple[int, GetOp]]
|
|
) -> list[PreparedGetQuery]:
|
|
"""
|
|
Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
|
|
|
|
Returns a list of PreparedGetQuery objects, which may include:
|
|
- Queries with kind='refresh' for TTL refresh operations
|
|
- Queries with kind='get' for data retrieval operations
|
|
"""
|
|
namespace_groups = defaultdict(list)
|
|
refresh_ttls = defaultdict(list)
|
|
for idx, op in get_ops:
|
|
namespace_groups[op.namespace].append((idx, op.key))
|
|
refresh_ttls[op.namespace].append(getattr(op, "refresh_ttl", False))
|
|
|
|
results = []
|
|
for namespace, items in namespace_groups.items():
|
|
_, keys = zip(*items, strict=False)
|
|
this_refresh_ttls = refresh_ttls[namespace]
|
|
refresh_ttl_any = any(this_refresh_ttls)
|
|
|
|
# Always add the main query to get the data
|
|
select_query = f"""
|
|
SELECT key, value, created_at, updated_at, expires_at, ttl_minutes
|
|
FROM store
|
|
WHERE prefix = ? AND key IN ({",".join(["?"] * len(keys))})
|
|
"""
|
|
select_params = (_namespace_to_text(namespace), *keys)
|
|
results.append(
|
|
PreparedGetQuery(select_query, select_params, namespace, items, "get")
|
|
)
|
|
|
|
# Add a TTL refresh query if needed
|
|
if (
|
|
refresh_ttl_any
|
|
and self.ttl_config
|
|
and self.ttl_config.get("refresh_on_read", False)
|
|
):
|
|
placeholders = ",".join(["?"] * len(keys))
|
|
update_query = f"""
|
|
UPDATE store
|
|
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
|
|
WHERE prefix = ?
|
|
AND key IN ({placeholders})
|
|
AND ttl_minutes IS NOT NULL
|
|
"""
|
|
update_params = (_namespace_to_text(namespace), *keys)
|
|
results.append(
|
|
PreparedGetQuery(
|
|
update_query, update_params, namespace, items, "refresh"
|
|
)
|
|
)
|
|
|
|
return results
|
|
|
|
def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
|
|
"""Helper to generate filter conditions."""
|
|
_validate_filter_key(key)
|
|
|
|
# We need to properly format values for SQLite JSON extraction comparison
|
|
if op == "$eq":
|
|
if isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') = ?", [value]
|
|
elif value is None:
|
|
return f"json_extract(value, '$.{key}') IS NULL", []
|
|
elif isinstance(value, bool):
|
|
# SQLite JSON stores booleans as integers
|
|
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
|
elif isinstance(value, (int, float)):
|
|
# Convert to float to handle inf, -inf, nan, and very large integers
|
|
# SQLite REAL can handle these cases better than INTEGER
|
|
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
|
elif op == "$gt":
|
|
# For numeric values, SQLite needs to compare as numbers, not strings
|
|
if isinstance(value, (int, float)):
|
|
# Convert to float to handle special values and very large integers
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') > ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
|
elif op == "$gte":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') >= ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
|
elif op == "$lt":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') < ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
|
elif op == "$lte":
|
|
if isinstance(value, (int, float)):
|
|
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
|
float(value)
|
|
]
|
|
elif isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') <= ?", [value]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
|
elif op == "$ne":
|
|
if isinstance(value, str):
|
|
return f"json_extract(value, '$.{key}') != ?", [value]
|
|
elif value is None:
|
|
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
|
elif isinstance(value, bool):
|
|
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
|
elif isinstance(value, (int, float)):
|
|
# Convert to float for consistency
|
|
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
|
else:
|
|
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
|
else:
|
|
raise ValueError(f"Unsupported operator: {op}")
|
|
|
|
@classmethod
|
|
@contextmanager
|
|
def from_conn_string(
|
|
cls,
|
|
conn_string: str,
|
|
*,
|
|
index: SqliteIndexConfig | None = None,
|
|
ttl: TTLConfig | None = None,
|
|
) -> Iterator[SqliteStore]:
|
|
"""Create a new SqliteStore instance from a connection string.
|
|
|
|
Args:
|
|
conn_string (str): The SQLite connection string.
|
|
index (Optional[SqliteIndexConfig]): The index configuration for the store.
|
|
ttl (Optional[TTLConfig]): The time-to-live configuration for the store.
|
|
|
|
Returns:
|
|
SqliteStore: A new SqliteStore instance.
|
|
"""
|
|
conn = sqlite3.connect(
|
|
conn_string,
|
|
check_same_thread=False,
|
|
isolation_level=None, # autocommit mode
|
|
)
|
|
try:
|
|
yield cls(conn, index=index, ttl=ttl)
|
|
finally:
|
|
conn.close()
|
|
|
|
@contextmanager
|
|
def _cursor(self, *, transaction: bool = True) -> Iterator[sqlite3.Cursor]:
|
|
"""Create a database cursor as a context manager.
|
|
|
|
Args:
|
|
transaction (bool): whether to use transaction for the DB operations
|
|
"""
|
|
if not self.is_setup:
|
|
self.setup()
|
|
with self.lock:
|
|
if transaction:
|
|
self.conn.execute("BEGIN")
|
|
|
|
cur = self.conn.cursor()
|
|
try:
|
|
yield cur
|
|
finally:
|
|
if transaction:
|
|
self.conn.execute("COMMIT")
|
|
cur.close()
|
|
|
|
def setup(self) -> None:
|
|
"""Set up the store database.
|
|
|
|
This method creates the necessary tables in the SQLite database if they don't
|
|
already exist and runs database migrations. It should be called before first use.
|
|
"""
|
|
|
|
with self.lock:
|
|
if self.is_setup:
|
|
return
|
|
# Create migrations table if it doesn't exist
|
|
self.conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
v INTEGER PRIMARY KEY
|
|
)
|
|
"""
|
|
)
|
|
|
|
# Check current migration version
|
|
cur = self.conn.execute(
|
|
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
version = -1
|
|
else:
|
|
version = row[0]
|
|
|
|
# Apply migrations
|
|
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
|
|
self.conn.executescript(sql)
|
|
self.conn.execute("INSERT INTO store_migrations (v) VALUES (?)", (v,))
|
|
|
|
# Apply vector migrations if index config is provided
|
|
if self.index_config:
|
|
# Create vector migrations table if it doesn't exist
|
|
self.conn.enable_load_extension(True)
|
|
sqlite_vec.load(self.conn)
|
|
self.conn.enable_load_extension(False)
|
|
self.conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS vector_migrations (
|
|
v INTEGER PRIMARY KEY
|
|
)
|
|
"""
|
|
)
|
|
|
|
# Check current vector migration version
|
|
cur = self.conn.execute(
|
|
"SELECT v FROM vector_migrations ORDER BY v DESC LIMIT 1"
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
version = -1
|
|
else:
|
|
version = row[0]
|
|
|
|
# Apply vector migrations
|
|
for v, sql in enumerate(
|
|
self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
|
|
):
|
|
self.conn.executescript(sql)
|
|
self.conn.execute(
|
|
"INSERT INTO vector_migrations (v) VALUES (?)", (v,)
|
|
)
|
|
|
|
self.is_setup = True
|
|
|
|
def sweep_ttl(self) -> int:
|
|
"""Delete expired store items based on TTL.
|
|
|
|
Returns:
|
|
int: The number of deleted items.
|
|
"""
|
|
with self._cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM store
|
|
WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP
|
|
"""
|
|
)
|
|
deleted_count = cur.rowcount
|
|
return deleted_count
|
|
|
|
def start_ttl_sweeper(
|
|
self, sweep_interval_minutes: int | None = None
|
|
) -> concurrent.futures.Future[None]:
|
|
"""Periodically delete expired store items based on TTL.
|
|
|
|
Returns:
|
|
Future that can be waited on or cancelled.
|
|
"""
|
|
if not self.ttl_config:
|
|
future: concurrent.futures.Future[None] = concurrent.futures.Future()
|
|
future.set_result(None)
|
|
return future
|
|
|
|
if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive():
|
|
logger.info("TTL sweeper thread is already running")
|
|
# Return a future that can be used to cancel the existing thread
|
|
future = concurrent.futures.Future()
|
|
future.add_done_callback(
|
|
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
|
|
)
|
|
return future
|
|
|
|
self._ttl_stop_event.clear()
|
|
|
|
interval = float(
|
|
sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
|
|
)
|
|
logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
|
|
|
|
future = concurrent.futures.Future()
|
|
|
|
def _sweep_loop() -> None:
|
|
try:
|
|
while not self._ttl_stop_event.is_set():
|
|
if self._ttl_stop_event.wait(interval * 60):
|
|
break
|
|
|
|
try:
|
|
expired_items = self.sweep_ttl()
|
|
if expired_items > 0:
|
|
logger.info(f"Store swept {expired_items} expired items")
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"Store TTL sweep iteration failed", exc_info=exc
|
|
)
|
|
future.set_result(None)
|
|
except Exception as exc:
|
|
future.set_exception(exc)
|
|
|
|
thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper")
|
|
self._ttl_sweeper_thread = thread
|
|
thread.start()
|
|
|
|
future.add_done_callback(
|
|
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
|
|
)
|
|
return future
|
|
|
|
def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
|
|
"""Stop the TTL sweeper thread if it's running.
|
|
|
|
Args:
|
|
timeout: Maximum time to wait for the thread to stop, in seconds.
|
|
If `None`, wait indefinitely.
|
|
|
|
Returns:
|
|
bool: True if the thread was successfully stopped or wasn't running,
|
|
False if the timeout was reached before the thread stopped.
|
|
"""
|
|
if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive():
|
|
return True
|
|
|
|
logger.info("Stopping TTL sweeper thread")
|
|
self._ttl_stop_event.set()
|
|
|
|
self._ttl_sweeper_thread.join(timeout)
|
|
success = not self._ttl_sweeper_thread.is_alive()
|
|
|
|
if success:
|
|
self._ttl_sweeper_thread = None
|
|
logger.info("TTL sweeper thread stopped")
|
|
else:
|
|
logger.warning("Timed out waiting for TTL sweeper thread to stop")
|
|
|
|
return success
|
|
|
|
def __del__(self) -> None:
|
|
"""Ensure the TTL sweeper thread is stopped when the object is garbage collected."""
|
|
if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"):
|
|
self.stop_ttl_sweeper(timeout=0.1)
|
|
|
|
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
"""Execute a batch of operations.
|
|
|
|
Args:
|
|
ops (Iterable[Op]): List of operations to execute
|
|
|
|
Returns:
|
|
list[Result]: Results of the operations
|
|
"""
|
|
grouped_ops, num_ops = _group_ops(ops)
|
|
results: list[Result] = [None] * num_ops
|
|
|
|
with self._cursor(transaction=True) as cur:
|
|
if GetOp in grouped_ops:
|
|
self._batch_get_ops(
|
|
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
|
|
)
|
|
|
|
if SearchOp in grouped_ops:
|
|
self._batch_search_ops(
|
|
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
results,
|
|
cur,
|
|
)
|
|
|
|
if ListNamespacesOp in grouped_ops:
|
|
self._batch_list_namespaces_ops(
|
|
cast(
|
|
Sequence[tuple[int, ListNamespacesOp]],
|
|
grouped_ops[ListNamespacesOp],
|
|
),
|
|
results,
|
|
cur,
|
|
)
|
|
if PutOp in grouped_ops:
|
|
self._batch_put_ops(
|
|
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
|
|
)
|
|
|
|
return results
|
|
|
|
def _batch_get_ops(
|
|
self,
|
|
get_ops: Sequence[tuple[int, GetOp]],
|
|
results: list[Result],
|
|
cur: sqlite3.Cursor,
|
|
) -> None:
|
|
# Group all queries by namespace to execute all operations for each namespace together
|
|
namespace_queries = defaultdict(list)
|
|
for prepared_query in self._get_batch_GET_ops_queries(get_ops):
|
|
namespace_queries[prepared_query.namespace].append(prepared_query)
|
|
|
|
# Process each namespace's operations
|
|
for namespace, queries in namespace_queries.items():
|
|
# Execute TTL refresh queries first
|
|
for query in queries:
|
|
if query.kind == "refresh":
|
|
try:
|
|
cur.execute(query.query, query.params)
|
|
except Exception as e:
|
|
raise ValueError(
|
|
f"Error executing TTL refresh: \n{query.query}\n{query.params}\n{e}"
|
|
) from e
|
|
|
|
# Then execute GET queries and process results
|
|
for query in queries:
|
|
if query.kind == "get":
|
|
try:
|
|
cur.execute(query.query, query.params)
|
|
except Exception as e:
|
|
raise ValueError(
|
|
f"Error executing GET query: \n{query.query}\n{query.params}\n{e}"
|
|
) from e
|
|
|
|
rows = cur.fetchall()
|
|
key_to_row = {
|
|
row[0]: {
|
|
"key": row[0],
|
|
"value": row[1],
|
|
"created_at": row[2],
|
|
"updated_at": row[3],
|
|
"expires_at": row[4] if len(row) > 4 else None,
|
|
"ttl_minutes": row[5] if len(row) > 5 else None,
|
|
}
|
|
for row in rows
|
|
}
|
|
|
|
# Process results for this query
|
|
for idx, key in query.items:
|
|
row = key_to_row.get(key)
|
|
if row:
|
|
results[idx] = _row_to_item(
|
|
namespace, row, loader=self._deserializer
|
|
)
|
|
else:
|
|
results[idx] = None
|
|
|
|
def _batch_put_ops(
|
|
self,
|
|
put_ops: Sequence[tuple[int, PutOp]],
|
|
cur: sqlite3.Cursor,
|
|
) -> None:
|
|
queries, embedding_request = self._prepare_batch_PUT_queries(put_ops)
|
|
if embedding_request:
|
|
if self.embeddings is None:
|
|
# Should not get here since the embedding config is required
|
|
# to return an embedding_request above
|
|
raise ValueError(
|
|
"Embedding configuration is required for vector operations "
|
|
f"(for semantic search). "
|
|
f"Please provide an Embeddings when initializing the {self.__class__.__name__}."
|
|
)
|
|
query, txt_params = embedding_request
|
|
# Update the params to replace the raw text with the vectors
|
|
vectors = self.embeddings.embed_documents(
|
|
[param[-1] for param in txt_params]
|
|
)
|
|
|
|
# Convert vectors to SQLite-friendly format
|
|
vector_params = []
|
|
for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False):
|
|
vector_params.extend(
|
|
[ns, k, pathname, sqlite_vec.serialize_float32(vector)]
|
|
)
|
|
|
|
queries.append((query, vector_params))
|
|
|
|
for query, params in queries:
|
|
cur.execute(query, params)
|
|
|
|
def _batch_search_ops(
|
|
self,
|
|
search_ops: Sequence[tuple[int, SearchOp]],
|
|
results: list[Result],
|
|
cur: sqlite3.Cursor,
|
|
) -> None:
|
|
prepared_queries, embedding_requests = self._prepare_batch_search_queries(
|
|
search_ops
|
|
)
|
|
|
|
# Setup similarity functions if they don't exist
|
|
if embedding_requests and self.embeddings:
|
|
# Generate embeddings for search queries
|
|
embeddings = self.embeddings.embed_documents(
|
|
[query for _, query in embedding_requests]
|
|
)
|
|
|
|
# Replace placeholders with actual embeddings
|
|
for (embed_req_idx, _), embedding in zip(
|
|
embedding_requests, embeddings, strict=False
|
|
):
|
|
if embed_req_idx < len(prepared_queries):
|
|
_params_list: list = prepared_queries[embed_req_idx][1]
|
|
for i, param in enumerate(_params_list):
|
|
if param is _PLACEHOLDER:
|
|
_params_list[i] = sqlite_vec.serialize_float32(embedding)
|
|
else:
|
|
logger.warning(
|
|
f"Embedding request index {embed_req_idx} out of bounds for prepared_queries."
|
|
)
|
|
|
|
for (original_op_idx, _), (query, params, needs_refresh) in zip(
|
|
search_ops, prepared_queries, strict=False
|
|
):
|
|
cur.execute(query, params)
|
|
rows = cur.fetchall()
|
|
|
|
if needs_refresh and rows and self.ttl_config:
|
|
keys_to_refresh = []
|
|
for row_data in rows:
|
|
keys_to_refresh.append((row_data[0], row_data[1]))
|
|
|
|
if keys_to_refresh:
|
|
updates_by_prefix = defaultdict(list)
|
|
for prefix_text, key_text in keys_to_refresh:
|
|
updates_by_prefix[prefix_text].append(key_text)
|
|
|
|
for prefix_text, key_list in updates_by_prefix.items():
|
|
placeholders = ",".join(["?"] * len(key_list))
|
|
update_query = f"""
|
|
UPDATE store
|
|
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
|
|
WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL
|
|
"""
|
|
update_params = (prefix_text, *key_list)
|
|
try:
|
|
cur.execute(update_query, update_params)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error during TTL refresh update for search: {e}"
|
|
)
|
|
|
|
if "score" in query: # Vector search query
|
|
items = [
|
|
_row_to_search_item(
|
|
_decode_ns_text(row[0]),
|
|
{
|
|
"key": row[1],
|
|
"value": row[2],
|
|
"created_at": row[3],
|
|
"updated_at": row[4],
|
|
"expires_at": row[5] if len(row) > 5 else None,
|
|
"ttl_minutes": row[6] if len(row) > 6 else None,
|
|
"score": row[7] if len(row) > 7 else None,
|
|
},
|
|
loader=self._deserializer,
|
|
)
|
|
for row in rows
|
|
]
|
|
else: # Regular search query
|
|
items = [
|
|
_row_to_search_item(
|
|
_decode_ns_text(row[0]),
|
|
{
|
|
"key": row[1],
|
|
"value": row[2],
|
|
"created_at": row[3],
|
|
"updated_at": row[4],
|
|
"expires_at": row[5] if len(row) > 5 else None,
|
|
"ttl_minutes": row[6] if len(row) > 6 else None,
|
|
},
|
|
loader=self._deserializer,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
results[original_op_idx] = items
|
|
|
|
def _batch_list_namespaces_ops(
|
|
self,
|
|
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
results: list[Result],
|
|
cur: sqlite3.Cursor,
|
|
) -> None:
|
|
queries = self._get_batch_list_namespaces_queries(list_ops)
|
|
for (query, params), (idx, _) in zip(queries, list_ops, strict=False):
|
|
cur.execute(query, params)
|
|
results[idx] = [_decode_ns_text(row[0]) for row in cur.fetchall()]
|
|
|
|
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
"""Async batch operation - not supported in SqliteStore.
|
|
|
|
Use AsyncSqliteStore for async operations.
|
|
"""
|
|
raise NotImplementedError(_AIO_ERROR_MSG)
|
|
|
|
|
|
# Helper functions
|
|
|
|
|
|
def _ensure_index_config(
|
|
index_config: SqliteIndexConfig,
|
|
) -> tuple[Any, SqliteIndexConfig]:
|
|
"""Process and validate index configuration."""
|
|
index_config = index_config.copy()
|
|
tokenized: list[tuple[str, Literal["$"] | list[str]]] = []
|
|
tot = 0
|
|
text_fields = index_config.get("text_fields") or ["$"]
|
|
if isinstance(text_fields, str):
|
|
text_fields = [text_fields]
|
|
if not isinstance(text_fields, list):
|
|
raise ValueError(f"Text fields must be a list or a string. Got {text_fields}")
|
|
for p in text_fields:
|
|
if p == "$":
|
|
tokenized.append((p, "$"))
|
|
tot += 1
|
|
else:
|
|
toks = tokenize_path(p)
|
|
tokenized.append((p, toks))
|
|
tot += len(toks)
|
|
index_config["__tokenized_fields"] = tokenized
|
|
index_config["__estimated_num_vectors"] = tot
|
|
embeddings = ensure_embeddings(
|
|
index_config.get("embed"),
|
|
)
|
|
return embeddings, index_config
|
|
|
|
|
|
_PLACEHOLDER = object()
|