langgraph-checkpoint-sqlite 2.0.8__tar.gz → 2.0.9__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.
Files changed (20) hide show
  1. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/PKG-INFO +1 -1
  2. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/store/sqlite/aio.py +2 -3
  3. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/store/sqlite/base.py +75 -379
  4. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/pyproject.toml +1 -1
  5. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/test_async_store.py +165 -0
  6. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/test_store.py +165 -0
  7. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/uv.lock +1 -1
  8. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/.gitignore +0 -0
  9. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/Makefile +0 -0
  10. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/README.md +0 -0
  11. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/cache/sqlite/__init__.py +0 -0
  12. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/checkpoint/sqlite/__init__.py +0 -0
  13. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/checkpoint/sqlite/aio.py +0 -0
  14. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/checkpoint/sqlite/py.typed +0 -0
  15. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/checkpoint/sqlite/utils.py +0 -0
  16. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/langgraph/store/sqlite/__init__.py +0 -0
  17. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/__init__.py +0 -0
  18. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/test_aiosqlite.py +0 -0
  19. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/test_sqlite.py +0 -0
  20. {langgraph_checkpoint_sqlite-2.0.8 → langgraph_checkpoint_sqlite-2.0.9}/tests/test_ttl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-sqlite
3
- Version: 2.0.8
3
+ Version: 2.0.9
4
4
  Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
5
5
  Project-URL: Repository, https://www.github.com/langchain-ai/langgraph
6
6
  License-Expression: MIT
@@ -511,12 +511,10 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
511
511
 
512
512
  # Setup dot_product function if it doesn't exist
513
513
  if embedding_requests and self.embeddings:
514
- # Generate embeddings for search queries
515
514
  vectors = await self.embeddings.aembed_documents(
516
515
  [query for _, query in embedding_requests]
517
516
  )
518
517
 
519
- # Replace placeholders with actual embeddings
520
518
  for (idx, _), embedding in zip(embedding_requests, vectors):
521
519
  _params_list: list = queries[idx][1]
522
520
  for i, param in enumerate(_params_list):
@@ -527,7 +525,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
527
525
  await cur.execute(query, params)
528
526
  rows = await cur.fetchall()
529
527
 
530
- if "score" in query: # Vector search query
528
+ if "score" in query:
531
529
  items = [
532
530
  _row_to_search_item(
533
531
  _decode_ns_text(row[0]),
@@ -579,5 +577,6 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
579
577
  queries = self._get_batch_list_namespaces_queries(list_ops)
580
578
  for (query, params), (idx, _) in zip(queries, list_ops):
581
579
  await cur.execute(query, params)
580
+
582
581
  rows = await cur.fetchall()
583
582
  results[idx] = [_decode_ns_text(row[0]) for row in rows]
@@ -530,63 +530,95 @@ class BaseSqliteStore:
530
530
  return queries, embedding_requests
531
531
 
532
532
  def _get_batch_list_namespaces_queries(
533
- self, list_ops: Sequence[tuple[int, ListNamespacesOp]]
533
+ self,
534
+ list_ops: Sequence[tuple[int, ListNamespacesOp]],
534
535
  ) -> list[tuple[str, Sequence]]:
535
536
  queries: list[tuple[str, Sequence]] = []
537
+
536
538
  for _, op in list_ops:
537
- # In SQLite, we need to use a different approach for namespace segmentation
538
- # since there's no direct equivalent to PostgreSQL's string aggregation
539
- if op.max_depth is not None:
540
- # SQLite doesn't have a built-in function for string splitting/joining with depth limit
541
- # We'll use a more basic approach
542
- query = """
543
- WITH RECURSIVE split_prefix(prefix, remainder, depth) AS (
544
- SELECT '', prefix || '.', 0 FROM (SELECT DISTINCT prefix FROM store)
545
- UNION ALL
546
- SELECT
547
- CASE WHEN instr(remainder, '.') > 0
548
- THEN prefix || CASE WHEN prefix = '' THEN '' ELSE '.' END || substr(remainder, 1, instr(remainder, '.') - 1)
549
- ELSE prefix || CASE WHEN prefix = '' THEN '' ELSE '.' END || remainder
550
- END,
551
- CASE WHEN instr(remainder, '.') > 0
552
- THEN substr(remainder, instr(remainder, '.') + 1)
553
- ELSE ''
554
- END,
555
- depth + 1
556
- FROM split_prefix
557
- WHERE remainder != '' AND depth < ?
558
- )
559
- SELECT DISTINCT prefix FROM split_prefix WHERE depth > 0
560
- """
561
- params: list[Any] = [op.max_depth]
562
- else:
563
- # If no max_depth is specified, we can just use a simpler query
564
- query = "SELECT DISTINCT prefix FROM store"
565
- params = []
539
+ where_clauses: list[str] = []
540
+ params: list[Any] = []
566
541
 
567
- conditions = []
568
542
  if op.match_conditions:
569
- for condition in op.match_conditions:
570
- if condition.match_type == "prefix":
571
- conditions.append("prefix LIKE ?")
543
+ for cond in op.match_conditions:
544
+ if cond.match_type == "prefix":
545
+ where_clauses.append("prefix LIKE ?")
572
546
  params.append(
573
- f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
547
+ f"{_namespace_to_text(cond.path, handle_wildcards=True)}%"
574
548
  )
575
- elif condition.match_type == "suffix":
576
- conditions.append("prefix LIKE ?")
549
+ elif cond.match_type == "suffix":
550
+ where_clauses.append("prefix LIKE ?")
577
551
  params.append(
578
- f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
552
+ f"%{_namespace_to_text(cond.path, handle_wildcards=True)}"
579
553
  )
580
554
  else:
581
555
  logger.warning(
582
- f"Unknown match_type in list_namespaces: {condition.match_type}"
556
+ "Unknown match_type in list_namespaces: %s", cond.match_type
583
557
  )
584
558
 
585
- if conditions:
586
- query += " WHERE " + " AND ".join(conditions)
559
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
560
+
561
+ if op.max_depth is not None:
562
+ query = f"""
563
+ WITH RECURSIVE split(original, truncated, remainder, depth) AS (
564
+ SELECT
565
+ prefix AS original,
566
+ '' AS truncated,
567
+ prefix AS remainder,
568
+ 0 AS depth
569
+ FROM (SELECT DISTINCT prefix FROM store {where_sql})
570
+
571
+ UNION ALL
572
+
573
+ SELECT
574
+ original,
575
+ CASE
576
+ WHEN depth = 0
577
+ THEN substr(remainder,
578
+ 1,
579
+ CASE
580
+ WHEN instr(remainder, '.') > 0
581
+ THEN instr(remainder, '.') - 1
582
+ ELSE length(remainder)
583
+ END)
584
+ ELSE
585
+ truncated || '.' ||
586
+ substr(remainder,
587
+ 1,
588
+ CASE
589
+ WHEN instr(remainder, '.') > 0
590
+ THEN instr(remainder, '.') - 1
591
+ ELSE length(remainder)
592
+ END)
593
+ END AS truncated,
594
+ CASE
595
+ WHEN instr(remainder, '.') > 0
596
+ THEN substr(remainder, instr(remainder, '.') + 1)
597
+ ELSE ''
598
+ END AS remainder,
599
+ depth + 1 AS depth
600
+ FROM split
601
+ WHERE remainder <> ''
602
+ AND depth < ?
603
+ )
604
+ SELECT DISTINCT truncated AS prefix
605
+ FROM split
606
+ WHERE depth = ? OR remainder = ''
607
+ ORDER BY prefix
608
+ LIMIT ? OFFSET ?
609
+ """
610
+ params.extend([op.max_depth, op.max_depth, op.limit, op.offset])
611
+
612
+ else:
613
+ query = f"""
614
+ SELECT DISTINCT prefix
615
+ FROM store
616
+ {where_sql}
617
+ ORDER BY prefix
618
+ LIMIT ? OFFSET ?
619
+ """
620
+ params.extend([op.limit, op.offset])
587
621
 
588
- query += " ORDER BY prefix LIMIT ? OFFSET ?"
589
- params.extend([op.limit, op.offset])
590
622
  queries.append((query, tuple(params)))
591
623
 
592
624
  return queries
@@ -828,342 +860,6 @@ class SqliteStore(BaseSqliteStore, BaseStore):
828
860
 
829
861
  return results
830
862
 
831
- def _prepare_batch_PUT_queries(
832
- self, put_ops: Sequence[tuple[int, PutOp]]
833
- ) -> tuple[
834
- list[tuple[str, Sequence]],
835
- Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
836
- ]:
837
- # Last-write wins
838
- dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
839
- for _, op in put_ops:
840
- dedupped_ops[(op.namespace, op.key)] = op
841
-
842
- inserts: list[PutOp] = []
843
- deletes: list[PutOp] = []
844
- for op in dedupped_ops.values():
845
- if op.value is None:
846
- deletes.append(op)
847
- else:
848
- inserts.append(op)
849
-
850
- queries: list[tuple[str, Sequence]] = []
851
-
852
- if deletes:
853
- namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
854
- for op in deletes:
855
- namespace_groups[op.namespace].append(op.key)
856
- for namespace, keys in namespace_groups.items():
857
- placeholders = ",".join(["?" for _ in keys])
858
- query = (
859
- f"DELETE FROM store WHERE prefix = ? AND key IN ({placeholders})"
860
- )
861
- params = (_namespace_to_text(namespace), *keys)
862
- queries.append((query, params))
863
-
864
- embedding_request: Optional[tuple[str, Sequence[tuple[str, str, str, str]]]] = (
865
- None
866
- )
867
- if inserts:
868
- values = []
869
- insertion_params = []
870
- vector_values = []
871
- embedding_request_params = []
872
- now = datetime.datetime.now(datetime.timezone.utc)
873
-
874
- # First handle main store insertions
875
- for op in inserts:
876
- if op.ttl is None:
877
- expires_at = None
878
- else:
879
- expires_at = now + datetime.timedelta(minutes=op.ttl)
880
- values.append("(?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)")
881
- insertion_params.extend(
882
- [
883
- _namespace_to_text(op.namespace),
884
- op.key,
885
- orjson.dumps(cast(dict, op.value)),
886
- expires_at,
887
- op.ttl,
888
- ]
889
- )
890
-
891
- # Then handle embeddings if configured
892
- if self.index_config:
893
- for op in inserts:
894
- if op.index is False:
895
- continue
896
- value = op.value
897
- ns = _namespace_to_text(op.namespace)
898
- k = op.key
899
-
900
- if op.index is None:
901
- paths = self.index_config["__tokenized_fields"]
902
- else:
903
- paths = [(ix, tokenize_path(ix)) for ix in op.index]
904
-
905
- for path, tokenized_path in paths:
906
- texts = get_text_at_path(value, tokenized_path)
907
- for i, text in enumerate(texts):
908
- pathname = f"{path}.{i}" if len(texts) > 1 else path
909
- vector_values.append(
910
- "(?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
911
- )
912
- embedding_request_params.append((ns, k, pathname, text))
913
-
914
- values_str = ",".join(values)
915
- query = f"""
916
- INSERT OR REPLACE INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
917
- VALUES {values_str}
918
- """
919
- queries.append((query, insertion_params))
920
-
921
- if vector_values:
922
- values_str = ",".join(vector_values)
923
- query = f"""
924
- INSERT OR REPLACE INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at)
925
- VALUES {values_str}
926
- """
927
- embedding_request = (query, embedding_request_params)
928
-
929
- return queries, embedding_request
930
-
931
- def _prepare_batch_search_queries(
932
- self, search_ops: Sequence[tuple[int, SearchOp]]
933
- ) -> tuple[
934
- list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
935
- list[tuple[int, str]], # idx, query_text pairs to embed
936
- ]:
937
- """
938
- Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests.
939
- Returns:
940
- - queries: list of (SQL, param_list)
941
- - embedding_requests: list of (original_index_in_search_ops, text_query)
942
- """
943
- queries = []
944
- embedding_requests = []
945
-
946
- for idx, (_, op) in enumerate(search_ops):
947
- # Build filter conditions first
948
- filter_params = []
949
- filter_conditions = []
950
- if op.filter:
951
- for key, value in op.filter.items():
952
- if isinstance(value, dict):
953
- for op_name, val in value.items():
954
- condition, filter_params_ = self._get_filter_condition(
955
- key, op_name, val
956
- )
957
- filter_conditions.append(condition)
958
- filter_params.extend(filter_params_)
959
- else:
960
- # SQLite json_extract returns unquoted string values
961
- if isinstance(value, str):
962
- filter_conditions.append(
963
- "json_extract(value, '$."
964
- + key
965
- + "') = '"
966
- + value.replace("'", "''")
967
- + "'"
968
- )
969
- elif value is None:
970
- filter_conditions.append(
971
- "json_extract(value, '$." + key + "') IS NULL"
972
- )
973
- elif isinstance(value, bool):
974
- # SQLite JSON stores booleans as integers
975
- filter_conditions.append(
976
- "json_extract(value, '$."
977
- + key
978
- + "') = "
979
- + ("1" if value else "0")
980
- )
981
- elif isinstance(value, (int, float)):
982
- filter_conditions.append(
983
- "json_extract(value, '$." + key + "') = " + str(value)
984
- )
985
- else:
986
- # For complex objects, use param binding with JSON serialization
987
- filter_conditions.append(
988
- "json_extract(value, '$." + key + "') = ?"
989
- )
990
- filter_params.append(orjson.dumps(value))
991
-
992
- # Vector search branch
993
- if op.query and self.index_config:
994
- embedding_requests.append((idx, op.query))
995
-
996
- # Choose the similarity function and score expression based on distance type
997
- distance_type = self.index_config.get("distance_type", "cosine")
998
-
999
- if distance_type == "cosine":
1000
- score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
1001
- elif distance_type == "l2":
1002
- score_expr = "vec_distance_L2(sv.embedding, ?)"
1003
- elif distance_type == "inner_product":
1004
- # For inner product, we want higher values to be better, so negate the result
1005
- # since inner product similarity is higher when vectors are more similar
1006
- score_expr = "-1 * vec_distance_L1(sv.embedding, ?)"
1007
- else:
1008
- # Default to cosine similarity
1009
- score_expr = "1.0 - vec_distance_cosine(sv.embedding, ?)"
1010
-
1011
- filter_str = (
1012
- ""
1013
- if not filter_conditions
1014
- else " AND " + " AND ".join(filter_conditions)
1015
- )
1016
- if op.namespace_prefix:
1017
- prefix_filter_str = f"WHERE s.prefix LIKE ? {filter_str} "
1018
- ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
1019
- else:
1020
- ns_args = ()
1021
- if filter_str:
1022
- prefix_filter_str = f"WHERE {filter_str[5:]} "
1023
- else:
1024
- prefix_filter_str = ""
1025
-
1026
- # We use a CTE to compute scores, with a SQLite-compatible approach for distinct results
1027
- base_query = f"""
1028
- WITH scored AS (
1029
- SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, s.expires_at, s.ttl_minutes,
1030
- {score_expr} AS score
1031
- FROM store s
1032
- JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
1033
- {prefix_filter_str}
1034
- ORDER BY score DESC
1035
- LIMIT ?
1036
- ),
1037
- ranked AS (
1038
- SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score,
1039
- ROW_NUMBER() OVER (PARTITION BY prefix, key ORDER BY score DESC) as rn
1040
- FROM scored
1041
- )
1042
- SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, score
1043
- FROM ranked
1044
- WHERE rn = 1
1045
- ORDER BY score DESC
1046
- LIMIT ?
1047
- OFFSET ?
1048
- """
1049
- params = [
1050
- _PLACEHOLDER, # Vector placeholder
1051
- *ns_args,
1052
- *filter_params,
1053
- op.limit * 2, # Expanded limit for better results
1054
- op.limit,
1055
- op.offset,
1056
- ]
1057
- # Regular search branch (no vector search)
1058
- else:
1059
- base_query = """
1060
- SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
1061
- FROM store
1062
- WHERE prefix LIKE ?
1063
- """
1064
- params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
1065
-
1066
- if filter_conditions:
1067
- params.extend(filter_params)
1068
- base_query += " AND " + " AND ".join(filter_conditions)
1069
-
1070
- base_query += " ORDER BY updated_at DESC"
1071
- base_query += " LIMIT ? OFFSET ?"
1072
- params.extend([op.limit, op.offset])
1073
-
1074
- # Debug the query
1075
- logger.debug(f"Search query: {base_query}")
1076
- logger.debug(f"Search params: {params}")
1077
-
1078
- # Handle TTL refresh if requested
1079
- if (
1080
- op.refresh_ttl
1081
- and self.ttl_config
1082
- and self.ttl_config.get("refresh_on_read", False)
1083
- ):
1084
- final_sql = f"""
1085
- WITH search_results AS (
1086
- {base_query}
1087
- ),
1088
- updated AS (
1089
- UPDATE store
1090
- SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
1091
- WHERE (prefix, key) IN (SELECT prefix, key FROM search_results)
1092
- AND ttl_minutes IS NOT NULL
1093
- )
1094
- SELECT * FROM search_results
1095
- """
1096
- final_params = params[:] # copy params
1097
- else:
1098
- final_sql = base_query
1099
- final_params = params
1100
-
1101
- queries.append((final_sql, final_params))
1102
-
1103
- return queries, embedding_requests
1104
-
1105
- def _get_batch_list_namespaces_queries(
1106
- self, list_ops: Sequence[tuple[int, ListNamespacesOp]]
1107
- ) -> list[tuple[str, Sequence]]:
1108
- queries: list[tuple[str, Sequence]] = []
1109
- for _, op in list_ops:
1110
- # In SQLite, we need to use a different approach for namespace segmentation
1111
- # since there's no direct equivalent to PostgreSQL's string aggregation
1112
- if op.max_depth is not None:
1113
- # SQLite doesn't have a built-in function for string splitting/joining with depth limit
1114
- # We'll use a more basic approach
1115
- query = """
1116
- WITH RECURSIVE split_prefix(prefix, remainder, depth) AS (
1117
- SELECT '', prefix || '.', 0 FROM (SELECT DISTINCT prefix FROM store)
1118
- UNION ALL
1119
- SELECT
1120
- CASE WHEN instr(remainder, '.') > 0
1121
- THEN prefix || CASE WHEN prefix = '' THEN '' ELSE '.' END || substr(remainder, 1, instr(remainder, '.') - 1)
1122
- ELSE prefix || CASE WHEN prefix = '' THEN '' ELSE '.' END || remainder
1123
- END,
1124
- CASE WHEN instr(remainder, '.') > 0
1125
- THEN substr(remainder, instr(remainder, '.') + 1)
1126
- ELSE ''
1127
- END,
1128
- depth + 1
1129
- FROM split_prefix
1130
- WHERE remainder != '' AND depth < ?
1131
- )
1132
- SELECT DISTINCT prefix FROM split_prefix WHERE depth > 0
1133
- """
1134
- params: list[Any] = [op.max_depth]
1135
- else:
1136
- # If no max_depth is specified, we can just use a simpler query
1137
- query = "SELECT DISTINCT prefix FROM store"
1138
- params = []
1139
-
1140
- conditions = []
1141
- if op.match_conditions:
1142
- for condition in op.match_conditions:
1143
- if condition.match_type == "prefix":
1144
- conditions.append("prefix LIKE ?")
1145
- params.append(
1146
- f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
1147
- )
1148
- elif condition.match_type == "suffix":
1149
- conditions.append("prefix LIKE ?")
1150
- params.append(
1151
- f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
1152
- )
1153
- else:
1154
- logger.warning(
1155
- f"Unknown match_type in list_namespaces: {condition.match_type}"
1156
- )
1157
-
1158
- if conditions:
1159
- query += " WHERE " + " AND ".join(conditions)
1160
-
1161
- query += " ORDER BY prefix LIMIT ? OFFSET ?"
1162
- params.extend([op.limit, op.offset])
1163
- queries.append((query, tuple(params)))
1164
-
1165
- return queries
1166
-
1167
863
  def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
1168
864
  """Helper to generate filter conditions."""
1169
865
  # We need to properly format values for SQLite JSON extraction comparison
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-sqlite"
7
- version = "2.0.8"
7
+ version = "2.0.9"
8
8
  description = "Library with a SQLite implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.9"
@@ -2,6 +2,7 @@
2
2
  import asyncio
3
3
  import os
4
4
  import tempfile
5
+ import uuid
5
6
  from collections.abc import AsyncIterator, Generator, Iterable
6
7
  from contextlib import asynccontextmanager
7
8
  from typing import Optional, Union, cast
@@ -492,3 +493,167 @@ async def test_embed_with_path(
492
493
  assert len(results) == 2
493
494
  assert results[0].score < 0.9
494
495
  assert results[1].score < 0.9
496
+
497
+
498
+ async def test_basic_store_ops(
499
+ fake_embeddings: CharacterEmbeddings,
500
+ ) -> None:
501
+ """Test vector search with specific text fields in SQLite store."""
502
+ async with create_vector_store(
503
+ fake_embeddings, text_fields=["key0", "key1", "key3"]
504
+ ) as store:
505
+ uid = uuid.uuid4().hex
506
+ namespace = (uid, "test", "documents")
507
+ item_id = "doc1"
508
+ item_value = {"title": "Test Document", "content": "Hello, World!"}
509
+ results = await store.asearch((uid,))
510
+ assert len(results) == 0
511
+
512
+ await store.aput(namespace, item_id, item_value)
513
+ item = await store.aget(namespace, item_id)
514
+
515
+ assert item is not None
516
+ assert item.namespace == namespace
517
+ assert item.key == item_id
518
+ assert item.value == item_value
519
+ assert item.created_at is not None
520
+ assert item.updated_at is not None
521
+
522
+ updated_value = {
523
+ "title": "Updated Test Document",
524
+ "content": "Hello, LangGraph!",
525
+ }
526
+ await asyncio.sleep(1.01)
527
+ await store.aput(namespace, item_id, updated_value)
528
+ updated_item = await store.aget(namespace, item_id)
529
+ assert updated_item is not None
530
+
531
+ assert updated_item.value == updated_value
532
+ assert updated_item.updated_at > item.updated_at
533
+ different_namespace = (uid, "test", "other_documents")
534
+ item_in_different_namespace = await store.aget(different_namespace, item_id)
535
+ assert item_in_different_namespace is None
536
+
537
+ new_item_id = "doc2"
538
+ new_item_value = {"title": "Another Document", "content": "Greetings!"}
539
+ await store.aput(namespace, new_item_id, new_item_value)
540
+
541
+ items = await store.asearch((uid, "test"), limit=10)
542
+ assert len(items) == 2
543
+ assert any(item.key == item_id for item in items)
544
+ assert any(item.key == new_item_id for item in items)
545
+
546
+ namespaces = await store.alist_namespaces(prefix=(uid, "test"))
547
+ assert (uid, "test", "documents") in namespaces
548
+
549
+ await store.adelete(namespace, item_id)
550
+ await store.adelete(namespace, new_item_id)
551
+ deleted_item = await store.aget(namespace, item_id)
552
+ assert deleted_item is None
553
+
554
+ deleted_item = await store.aget(namespace, new_item_id)
555
+ assert deleted_item is None
556
+
557
+ empty_search_results = await store.asearch((uid, "test"), limit=10)
558
+ assert len(empty_search_results) == 0
559
+
560
+
561
+ async def test_list_namespaces(
562
+ fake_embeddings: CharacterEmbeddings,
563
+ ) -> None:
564
+ """Test list namespaces functionality with various filters."""
565
+ async with create_vector_store(
566
+ fake_embeddings, text_fields=["key0", "key1", "key3"]
567
+ ) as store:
568
+ test_pref = str(uuid.uuid4())
569
+ test_namespaces = [
570
+ (test_pref, "test", "documents", "public", test_pref),
571
+ (test_pref, "test", "documents", "private", test_pref),
572
+ (test_pref, "test", "images", "public", test_pref),
573
+ (test_pref, "test", "images", "private", test_pref),
574
+ (test_pref, "prod", "documents", "public", test_pref),
575
+ (test_pref, "prod", "documents", "some", "nesting", "public", test_pref),
576
+ (test_pref, "prod", "documents", "private", test_pref),
577
+ ]
578
+
579
+ # Add test data
580
+ for namespace in test_namespaces:
581
+ await store.aput(namespace, "dummy", {"content": "dummy"})
582
+
583
+ # Test prefix filtering
584
+ prefix_result = await store.alist_namespaces(prefix=(test_pref, "test"))
585
+ assert len(prefix_result) == 4
586
+ assert all(ns[1] == "test" for ns in prefix_result)
587
+
588
+ # Test specific prefix
589
+ specific_prefix_result = await store.alist_namespaces(
590
+ prefix=(test_pref, "test", "documents")
591
+ )
592
+ assert len(specific_prefix_result) == 2
593
+ assert all(ns[1:3] == ("test", "documents") for ns in specific_prefix_result)
594
+
595
+ # Test suffix filtering
596
+ suffix_result = await store.alist_namespaces(suffix=("public", test_pref))
597
+ assert len(suffix_result) == 4
598
+ assert all(ns[-2] == "public" for ns in suffix_result)
599
+
600
+ # Test combined prefix and suffix
601
+ prefix_suffix_result = await store.alist_namespaces(
602
+ prefix=(test_pref, "test"), suffix=("public", test_pref)
603
+ )
604
+ assert len(prefix_suffix_result) == 2
605
+ assert all(
606
+ ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
607
+ )
608
+
609
+ # Test wildcard in prefix
610
+ wildcard_prefix_result = await store.alist_namespaces(
611
+ prefix=(test_pref, "*", "documents")
612
+ )
613
+ assert len(wildcard_prefix_result) == 5
614
+ assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
615
+
616
+ # Test wildcard in suffix
617
+ wildcard_suffix_result = await store.alist_namespaces(
618
+ suffix=("*", "public", test_pref)
619
+ )
620
+ assert len(wildcard_suffix_result) == 4
621
+ assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
622
+
623
+ wildcard_single = await store.alist_namespaces(
624
+ suffix=("some", "*", "public", test_pref)
625
+ )
626
+ assert len(wildcard_single) == 1
627
+ assert wildcard_single[0] == (
628
+ test_pref,
629
+ "prod",
630
+ "documents",
631
+ "some",
632
+ "nesting",
633
+ "public",
634
+ test_pref,
635
+ )
636
+
637
+ # Test max depth
638
+ max_depth_result = await store.alist_namespaces(max_depth=3)
639
+ assert all(len(ns) <= 3 for ns in max_depth_result)
640
+
641
+ max_depth_result = await store.alist_namespaces(
642
+ max_depth=4, prefix=(test_pref, "*", "documents")
643
+ )
644
+ assert len(set(res for res in max_depth_result)) == len(max_depth_result) == 5
645
+
646
+ # Test pagination
647
+ limit_result = await store.alist_namespaces(prefix=(test_pref,), limit=3)
648
+ assert len(limit_result) == 3
649
+
650
+ offset_result = await store.alist_namespaces(prefix=(test_pref,), offset=3)
651
+ assert len(offset_result) == len(test_namespaces) - 3
652
+
653
+ empty_prefix_result = await store.alist_namespaces(prefix=(test_pref,))
654
+ assert len(empty_prefix_result) == len(test_namespaces)
655
+ assert set(empty_prefix_result) == set(test_namespaces)
656
+
657
+ # Clean up
658
+ for namespace in test_namespaces:
659
+ await store.adelete(namespace, "dummy")
@@ -2,6 +2,7 @@
2
2
  import os
3
3
  import re
4
4
  import tempfile
5
+ import uuid
5
6
  from collections.abc import Generator, Iterable
6
7
  from contextlib import contextmanager
7
8
  from typing import Any, Literal, Optional, Union, cast
@@ -822,3 +823,167 @@ def test_nonnull_migrations() -> None:
822
823
  for migration in SqliteStore.MIGRATIONS:
823
824
  statement = _leading_comment_remover.sub("", migration).split()[0]
824
825
  assert statement.strip(), f"Empty migration statement found: {migration}"
826
+
827
+
828
+ def test_basic_store_operations(
829
+ fake_embeddings: CharacterEmbeddings,
830
+ ) -> None:
831
+ """Test basic store operations with SQLite store."""
832
+ with create_vector_store(
833
+ fake_embeddings, text_fields=["key0", "key1", "key3"]
834
+ ) as store:
835
+ uid = uuid.uuid4().hex
836
+ namespace = (uid, "test", "documents")
837
+ item_id = "doc1"
838
+ item_value = {"title": "Test Document", "content": "Hello, World!"}
839
+ results = store.search((uid,))
840
+ assert len(results) == 0
841
+
842
+ store.put(namespace, item_id, item_value)
843
+ item = store.get(namespace, item_id)
844
+
845
+ assert item is not None
846
+ assert item.namespace == namespace
847
+ assert item.key == item_id
848
+ assert item.value == item_value
849
+ assert item.created_at is not None
850
+ assert item.updated_at is not None
851
+
852
+ updated_value = {
853
+ "title": "Updated Test Document",
854
+ "content": "Hello, LangGraph!",
855
+ }
856
+ store.put(namespace, item_id, updated_value)
857
+ updated_item = store.get(namespace, item_id)
858
+ assert updated_item is not None
859
+
860
+ assert updated_item.value == updated_value
861
+ assert updated_item.updated_at >= item.updated_at
862
+
863
+ different_namespace = (uid, "test", "other_documents")
864
+ item_in_different_namespace = store.get(different_namespace, item_id)
865
+ assert item_in_different_namespace is None
866
+
867
+ new_item_id = "doc2"
868
+ new_item_value = {"title": "Another Document", "content": "Greetings!"}
869
+ store.put(namespace, new_item_id, new_item_value)
870
+
871
+ items = store.search((uid, "test"), limit=10)
872
+ assert len(items) == 2
873
+ assert any(item.key == item_id for item in items)
874
+ assert any(item.key == new_item_id for item in items)
875
+
876
+ namespaces = store.list_namespaces(prefix=(uid, "test"))
877
+ assert (uid, "test", "documents") in namespaces
878
+
879
+ store.delete(namespace, item_id)
880
+ store.delete(namespace, new_item_id)
881
+ deleted_item = store.get(namespace, item_id)
882
+ assert deleted_item is None
883
+
884
+ deleted_item = store.get(namespace, new_item_id)
885
+ assert deleted_item is None
886
+
887
+ empty_search_results = store.search((uid, "test"), limit=10)
888
+ assert len(empty_search_results) == 0
889
+
890
+
891
+ def test_list_namespaces_operations(
892
+ fake_embeddings: CharacterEmbeddings,
893
+ ) -> None:
894
+ """Test list namespaces functionality with various filters."""
895
+ with create_vector_store(
896
+ fake_embeddings, text_fields=["key0", "key1", "key3"]
897
+ ) as store:
898
+ test_pref = str(uuid.uuid4())
899
+ test_namespaces = [
900
+ (test_pref, "test", "documents", "public", test_pref),
901
+ (test_pref, "test", "documents", "private", test_pref),
902
+ (test_pref, "test", "images", "public", test_pref),
903
+ (test_pref, "test", "images", "private", test_pref),
904
+ (test_pref, "prod", "documents", "public", test_pref),
905
+ (test_pref, "prod", "documents", "some", "nesting", "public", test_pref),
906
+ (test_pref, "prod", "documents", "private", test_pref),
907
+ ]
908
+
909
+ # Add test data
910
+ for namespace in test_namespaces:
911
+ store.put(namespace, "dummy", {"content": "dummy"})
912
+
913
+ # Test prefix filtering
914
+ prefix_result = store.list_namespaces(prefix=(test_pref, "test"))
915
+ assert len(prefix_result) == 4
916
+ assert all(ns[1] == "test" for ns in prefix_result)
917
+
918
+ # Test specific prefix
919
+ specific_prefix_result = store.list_namespaces(
920
+ prefix=(test_pref, "test", "documents")
921
+ )
922
+ assert len(specific_prefix_result) == 2
923
+ assert all(ns[1:3] == ("test", "documents") for ns in specific_prefix_result)
924
+
925
+ # Test suffix filtering
926
+ suffix_result = store.list_namespaces(suffix=("public", test_pref))
927
+ assert len(suffix_result) == 4
928
+ assert all(ns[-2] == "public" for ns in suffix_result)
929
+
930
+ # Test combined prefix and suffix
931
+ prefix_suffix_result = store.list_namespaces(
932
+ prefix=(test_pref, "test"), suffix=("public", test_pref)
933
+ )
934
+ assert len(prefix_suffix_result) == 2
935
+ assert all(
936
+ ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
937
+ )
938
+
939
+ # Test wildcard in prefix
940
+ wildcard_prefix_result = store.list_namespaces(
941
+ prefix=(test_pref, "*", "documents")
942
+ )
943
+ assert len(wildcard_prefix_result) == 5
944
+ assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
945
+
946
+ # Test wildcard in suffix
947
+ wildcard_suffix_result = store.list_namespaces(
948
+ suffix=("*", "public", test_pref)
949
+ )
950
+ assert len(wildcard_suffix_result) == 4
951
+ assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
952
+
953
+ wildcard_single = store.list_namespaces(
954
+ suffix=("some", "*", "public", test_pref)
955
+ )
956
+ assert len(wildcard_single) == 1
957
+ assert wildcard_single[0] == (
958
+ test_pref,
959
+ "prod",
960
+ "documents",
961
+ "some",
962
+ "nesting",
963
+ "public",
964
+ test_pref,
965
+ )
966
+
967
+ # Test max depth
968
+ max_depth_result = store.list_namespaces(max_depth=3)
969
+ assert all(len(ns) <= 3 for ns in max_depth_result)
970
+
971
+ max_depth_result = store.list_namespaces(
972
+ max_depth=4, prefix=(test_pref, "*", "documents")
973
+ )
974
+ assert len(set(res for res in max_depth_result)) == len(max_depth_result) == 5
975
+
976
+ # Test pagination
977
+ limit_result = store.list_namespaces(prefix=(test_pref,), limit=3)
978
+ assert len(limit_result) == 3
979
+
980
+ offset_result = store.list_namespaces(prefix=(test_pref,), offset=3)
981
+ assert len(offset_result) == len(test_namespaces) - 3
982
+
983
+ empty_prefix_result = store.list_namespaces(prefix=(test_pref,))
984
+ assert len(empty_prefix_result) == len(test_namespaces)
985
+ assert set(empty_prefix_result) == set(test_namespaces)
986
+
987
+ # Clean up
988
+ for namespace in test_namespaces:
989
+ store.delete(namespace, "dummy")
@@ -346,7 +346,7 @@ dev = [
346
346
 
347
347
  [[package]]
348
348
  name = "langgraph-checkpoint-sqlite"
349
- version = "2.0.8"
349
+ version = "2.0.9"
350
350
  source = { editable = "." }
351
351
  dependencies = [
352
352
  { name = "aiosqlite" },