langgraph-checkpoint-sqlite 3.1.0__tar.gz → 3.1.1__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 (25) hide show
  1. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/Makefile +2 -5
  2. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/PKG-INFO +32 -2
  3. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/README.md +31 -1
  4. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/aio.py +9 -0
  5. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/base.py +103 -20
  6. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/pyproject.toml +16 -12
  7. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_async_store.py +29 -1
  8. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_store.py +212 -2
  9. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/uv.lock +139 -206
  10. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/.gitignore +0 -0
  11. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/LICENSE +0 -0
  12. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/cache/sqlite/__init__.py +0 -0
  13. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/__init__.py +0 -0
  14. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/_delta.py +0 -0
  15. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/aio.py +0 -0
  16. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/py.typed +0 -0
  17. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/utils.py +0 -0
  18. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/__init__.py +0 -0
  19. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/__init__.py +0 -0
  20. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_aiosqlite.py +0 -0
  21. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_conformance_delta.py +0 -0
  22. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_delta_channel_migration.py +0 -0
  23. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_get_delta_channel_history.py +0 -0
  24. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_sqlite.py +0 -0
  25. {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_ttl.py +0 -0
@@ -18,22 +18,19 @@ test_watch:
18
18
 
19
19
  # Define a variable for Python and notebook files.
20
20
  PYTHON_FILES=.
21
- MYPY_CACHE=.mypy_cache
22
21
  lint format: PYTHON_FILES=.
23
22
  lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
24
23
  lint_package: PYTHON_FILES=langgraph
25
24
  lint_tests: PYTHON_FILES=tests
26
- lint_tests: MYPY_CACHE=.mypy_cache_test
27
25
 
28
26
  lint lint_diff lint_package lint_tests:
29
27
  uv run ruff check .
30
28
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
31
29
  [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
32
- [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
33
- [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
30
+ [ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES)
34
31
 
35
32
  type:
36
- mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
33
+ uv run ty check $(PYTHON_FILES)
37
34
 
38
35
  format format_diff:
39
36
  uv run ruff format $(PYTHON_FILES)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-sqlite
3
- Version: 3.1.0
3
+ Version: 3.1.1
4
4
  Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
5
5
  Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
6
6
  Project-URL: Twitter, https://x.com/langchain_oss
@@ -16,7 +16,27 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  # LangGraph SQLite Checkpoint
18
18
 
19
- Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
19
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-sqlite?label=%20)](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
20
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-sqlite)](https://opensource.org/licenses/MIT)
21
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-sqlite)](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
22
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
23
+
24
+ To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
25
+ [LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
26
+
27
+ ## Quick Install
28
+
29
+ ```bash
30
+ uv add langgraph-checkpoint-sqlite
31
+ ```
32
+
33
+ ## 🤔 What is this?
34
+
35
+ This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
36
+
37
+ ## 📖 Documentation
38
+
39
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
20
40
 
21
41
  ## Security
22
42
 
@@ -107,3 +127,13 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
107
127
  # list checkpoints
108
128
  [c async for c in checkpointer.alist(read_config)]
109
129
  ```
130
+
131
+ ## 📕 Releases & Versioning
132
+
133
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
134
+
135
+ ## 💁 Contributing
136
+
137
+ As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
138
+
139
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -1,6 +1,26 @@
1
1
  # LangGraph SQLite Checkpoint
2
2
 
3
- Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-sqlite?label=%20)](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
4
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-sqlite)](https://opensource.org/licenses/MIT)
5
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-sqlite)](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
6
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
7
+
8
+ To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
9
+ [LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
10
+
11
+ ## Quick Install
12
+
13
+ ```bash
14
+ uv add langgraph-checkpoint-sqlite
15
+ ```
16
+
17
+ ## 🤔 What is this?
18
+
19
+ This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
20
+
21
+ ## 📖 Documentation
22
+
23
+ For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
4
24
 
5
25
  ## Security
6
26
 
@@ -91,3 +111,13 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
91
111
  # list checkpoints
92
112
  [c async for c in checkpointer.alist(read_config)]
93
113
  ```
114
+
115
+ ## 📕 Releases & Versioning
116
+
117
+ See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
118
+
119
+ ## 💁 Contributing
120
+
121
+ As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
122
+
123
+ For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
@@ -24,11 +24,13 @@ from langgraph.store.base.batch import AsyncBatchedBaseStore
24
24
 
25
25
  from langgraph.store.sqlite.base import (
26
26
  _PLACEHOLDER,
27
+ NS_MATCH_FUNCTION,
27
28
  BaseSqliteStore,
28
29
  SqliteIndexConfig,
29
30
  _decode_ns_text,
30
31
  _ensure_index_config,
31
32
  _group_ops,
33
+ _namespace_match,
32
34
  _row_to_item,
33
35
  _row_to_search_item,
34
36
  )
@@ -150,6 +152,13 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
150
152
  if self.is_setup:
151
153
  return
152
154
 
155
+ # list_namespaces needs segment-aware matching, which SQLite cannot
156
+ # express in LIKE or GLOB. Registered here rather than in __init__
157
+ # because aiosqlite's create_function is a coroutine.
158
+ await self.conn.create_function(
159
+ NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True
160
+ )
161
+
153
162
  # Create migrations table if it doesn't exist
154
163
  await self.conn.execute(
155
164
  """
@@ -93,12 +93,74 @@ class SqliteIndexConfig(IndexConfig):
93
93
  pass
94
94
 
95
95
 
96
- def _namespace_to_text(
97
- namespace: tuple[str, ...], handle_wildcards: bool = False
98
- ) -> str:
96
+ NS_MATCH_FUNCTION = "_langgraph_namespace_match"
97
+ """SQLite user function backing segment-aware namespace matching.
98
+
99
+ Registered under a private name rather than overriding `REGEXP`, so a caller's
100
+ own `REGEXP` is left untouched.
101
+ """
102
+
103
+
104
+ def _namespace_match(prefix: str | None, pattern: str) -> int:
105
+ """Backing implementation of `NS_MATCH_FUNCTION`."""
106
+ if prefix is None:
107
+ return 0
108
+ return 1 if re.search(pattern, prefix) else 0
109
+
110
+
111
+ def _escape_glob_literal(text: str) -> str:
112
+ """Escape GLOB metacharacters so `text` is matched literally.
113
+
114
+ GLOB has no `ESCAPE` clause, so metacharacters are wrapped in a character
115
+ class instead. `]` is literal outside a class and needs no escaping.
116
+ """
117
+ return text.replace("[", "[[]").replace("*", "[*]").replace("?", "[?]")
118
+
119
+
120
+ def _namespace_prefix_condition(
121
+ namespace_prefix: tuple[str, ...], column: str = "prefix"
122
+ ) -> tuple[str, tuple[str, ...]]:
123
+ """Build the SQL scoping a search to a namespace and its descendants.
124
+
125
+ Matches the namespace exactly or requires the `.` separator before any
126
+ remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
127
+
128
+ Uses GLOB rather than LIKE because SQLite's LIKE is case-insensitive for
129
+ ASCII, which would match `("FOO",)` for a prefix of `("foo",)` even though
130
+ `get`/`put`/`delete` compare with `=` and treat those as distinct.
131
+
132
+ An empty prefix is unconstrained and matches every namespace.
133
+ """
134
+ if not namespace_prefix:
135
+ return "TRUE", ()
136
+ path = _namespace_to_text(namespace_prefix)
137
+ condition = f"({column} = ? OR {column} GLOB ?)"
138
+ return condition, (path, f"{_escape_glob_literal(path)}.*")
139
+
140
+
141
+ def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
142
+ """Build a regex matching the dot-joined prefix on whole namespace segments.
143
+
144
+ Needed because neither LIKE nor GLOB can express "any character except the
145
+ separator": GLOB has character classes but no quantifier, so `[^.]*` still
146
+ crosses `.`. Matches how `InMemoryStore` compares namespaces element-wise.
147
+
148
+ `*` matches exactly one segment. Prefix matches stay open-ended but must end
149
+ on a separator; suffix matches anchor at the end and begin on one.
150
+
151
+ Examples:
152
+ prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
153
+ suffix ("alice",) -> (^|\\.)alice\\Z
154
+ """
155
+ segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
156
+ body = r"\.".join(segments)
157
+ if match_type == "suffix":
158
+ return rf"(^|\.){body}\Z"
159
+ return rf"^{body}(\.|\Z)"
160
+
161
+
162
+ def _namespace_to_text(namespace: tuple[str, ...]) -> str:
99
163
  """Convert namespace tuple to text string."""
100
- if handle_wildcards:
101
- namespace = tuple("%" if val == "*" else val for val in namespace)
102
164
  return ".".join(namespace)
103
165
 
104
166
 
@@ -461,8 +523,11 @@ class BaseSqliteStore:
461
523
  else " AND " + " AND ".join(filter_conditions)
462
524
  )
463
525
  if op.namespace_prefix:
464
- prefix_filter_str = f"WHERE s.prefix LIKE ? {filter_str} "
465
- ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
526
+ ns_condition, ns_args_tuple = _namespace_prefix_condition(
527
+ op.namespace_prefix, column="s.prefix"
528
+ )
529
+ prefix_filter_str = f"WHERE {ns_condition} {filter_str} "
530
+ ns_args: Sequence = ns_args_tuple
466
531
  else:
467
532
  ns_args = ()
468
533
  if filter_str:
@@ -503,12 +568,15 @@ class BaseSqliteStore:
503
568
  ]
504
569
  # Regular search branch (no vector search)
505
570
  else:
506
- base_query = """
571
+ ns_condition, ns_args_tuple = _namespace_prefix_condition(
572
+ op.namespace_prefix
573
+ )
574
+ base_query = f"""
507
575
  SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
508
576
  FROM store
509
- WHERE prefix LIKE ?
577
+ WHERE {ns_condition}
510
578
  """
511
- params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
579
+ params = list(ns_args_tuple)
512
580
 
513
581
  if filter_conditions:
514
582
  params.extend(filter_params)
@@ -549,16 +617,28 @@ class BaseSqliteStore:
549
617
 
550
618
  if op.match_conditions:
551
619
  for cond in op.match_conditions:
552
- if cond.match_type == "prefix":
553
- where_clauses.append("prefix LIKE ?")
554
- params.append(
555
- f"{_namespace_to_text(cond.path, handle_wildcards=True)}%"
556
- )
557
- elif cond.match_type == "suffix":
558
- where_clauses.append("prefix LIKE ?")
559
- params.append(
560
- f"%{_namespace_to_text(cond.path, handle_wildcards=True)}"
561
- )
620
+ if cond.match_type in ("prefix", "suffix"):
621
+ if not cond.path:
622
+ # An empty path constrains nothing; skipping keeps it a
623
+ # no-op rather than emitting a pattern that matches no
624
+ # namespace at all.
625
+ continue
626
+ if cond.match_type == "prefix" and "*" not in cond.path:
627
+ # Equivalent to the anchored pattern, but SQLite can
628
+ # satisfy `=` and a trailing-wildcard GLOB from
629
+ # store_prefix_idx. The user function is opaque to the
630
+ # planner, so it would scan every row and call back
631
+ # into Python for each one.
632
+ condition, args = _namespace_prefix_condition(
633
+ tuple(cond.path)
634
+ )
635
+ where_clauses.append(condition)
636
+ params.extend(args)
637
+ else:
638
+ where_clauses.append(f"{NS_MATCH_FUNCTION}(prefix, ?) = 1")
639
+ params.append(
640
+ _namespace_match_pattern(cond.path, cond.match_type)
641
+ )
562
642
  else:
563
643
  logger.warning(
564
644
  "Unknown match_type in list_namespaces: %s", cond.match_type
@@ -785,6 +865,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
785
865
  super().__init__()
786
866
  self._deserializer = deserializer
787
867
  self.conn = conn
868
+ # Registered here rather than in from_conn_string so a caller-supplied
869
+ # connection also gets it.
870
+ conn.create_function(NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True)
788
871
  self.lock = threading.Lock()
789
872
  self.is_setup = False
790
873
  self.index_config = index
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-sqlite"
7
- version = "3.1.0"
7
+ version = "3.1.1"
8
8
  description = "Library with a SQLite implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -35,7 +35,7 @@ test = [
35
35
  lint = [
36
36
  "ruff",
37
37
  "codespell",
38
- "mypy",
38
+ "ty",
39
39
  ]
40
40
  dev = [
41
41
  {include-group = "test"},
@@ -67,18 +67,22 @@ lint.select = [
67
67
  lint.ignore = ["E501", "B008"]
68
68
  target-version = "py310"
69
69
 
70
+ [tool.ty.rules]
71
+ invalid-argument-type = "ignore"
72
+ invalid-assignment = "ignore"
73
+ invalid-key = "ignore"
74
+ invalid-method-override = "ignore"
75
+ invalid-return-type = "ignore"
76
+ invalid-yield = "ignore"
77
+ not-iterable = "ignore"
78
+ not-subscriptable = "ignore"
79
+ unused-type-ignore-comment = "ignore"
80
+ unresolved-attribute = "ignore"
81
+ unresolved-import = "ignore"
82
+ unsupported-operator = "ignore"
83
+
70
84
  [tool.pytest-watcher]
71
85
  now = true
72
86
  delay = 0.1
73
87
  runner_args = ["--ff", "-v", "--tb", "short"]
74
88
  patterns = ["*.py"]
75
-
76
- [tool.mypy]
77
- # https://mypy.readthedocs.io/en/stable/config_file.html
78
- disallow_untyped_defs = "True"
79
- explicit_package_bases = "True"
80
- warn_no_return = "False"
81
- warn_unused_ignores = "True"
82
- warn_redundant_casts = "True"
83
- allow_redefinition = "True"
84
- disable_error_code = "typeddict-item, return-value"
@@ -1,4 +1,3 @@
1
- # mypy: disable-error-code="union-attr,arg-type,index,operator"
2
1
  import asyncio
3
2
  import os
4
3
  import tempfile
@@ -717,3 +716,32 @@ async def test_search_items(
717
716
  for ns in test_namespaces:
718
717
  key = f"item_{ns[-1]}"
719
718
  await store.adelete(ns, key)
719
+
720
+
721
+ async def test_async_namespace_segment_boundary(store: AsyncSqliteStore) -> None:
722
+ """Segment-aware scoping on the async path.
723
+
724
+ Also covers that the namespace-match SQLite function is registered on the
725
+ async connection -- aiosqlite's create_function is a coroutine, so it is
726
+ registered in setup() rather than __init__.
727
+ """
728
+ for namespace in [
729
+ ("foo",),
730
+ ("foo", "child"),
731
+ ("foobar",),
732
+ ("uid", "users", "alice"),
733
+ ("uid", "users", "malice"),
734
+ ("user_1",),
735
+ ("userX1",),
736
+ ]:
737
+ await store.aput(namespace, "k", {"v": 1})
738
+
739
+ found = {item.namespace for item in await store.asearch(("foo",), limit=100)}
740
+ assert found == {("foo",), ("foo", "child")}
741
+
742
+ found = {item.namespace for item in await store.asearch(("user_1",), limit=100)}
743
+ assert found == {("user_1",)}
744
+
745
+ assert set(await store.alist_namespaces(suffix=["alice"], limit=100)) == {
746
+ ("uid", "users", "alice"),
747
+ }
@@ -1,4 +1,3 @@
1
- # mypy: disable-error-code="union-attr,arg-type,index,operator"
2
1
  import os
3
2
  import re
4
3
  import tempfile
@@ -19,7 +18,13 @@ from langgraph.store.base import (
19
18
  )
20
19
 
21
20
  from langgraph.store.sqlite import SqliteStore
22
- from langgraph.store.sqlite.base import SqliteIndexConfig
21
+ from langgraph.store.sqlite.base import (
22
+ NS_MATCH_FUNCTION,
23
+ BaseSqliteStore,
24
+ SqliteIndexConfig,
25
+ _escape_glob_literal,
26
+ _namespace_match_pattern,
27
+ )
23
28
 
24
29
 
25
30
  # Local embeddings implementation for testing vector search
@@ -1230,3 +1235,208 @@ def test_non_ascii(
1230
1235
  assert result3[0].key == "3"
1231
1236
  assert result4[0].key == "4"
1232
1237
  assert result5[0].key == "5"
1238
+
1239
+
1240
+ def test_escape_glob_literal() -> None:
1241
+ assert _escape_glob_literal("users.alice") == "users.alice"
1242
+ # "_" and "%" are LIKE wildcards but literal in GLOB, so they are left alone.
1243
+ assert _escape_glob_literal("user_1") == "user_1"
1244
+ assert _escape_glob_literal("100%") == "100%"
1245
+ assert _escape_glob_literal("a*b") == "a[*]b"
1246
+ assert _escape_glob_literal("a?b") == "a[?]b"
1247
+ assert _escape_glob_literal("a[b") == "a[[]b"
1248
+
1249
+
1250
+ def test_namespace_match_pattern() -> None:
1251
+ assert _namespace_match_pattern(("foo",), "prefix") == r"^foo(\.|\Z)"
1252
+ assert (
1253
+ _namespace_match_pattern(("uid", "*", "alice"), "prefix")
1254
+ == r"^uid\.[^.]+\.alice(\.|\Z)"
1255
+ )
1256
+ assert _namespace_match_pattern(("alice",), "suffix") == r"(^|\.)alice\Z"
1257
+
1258
+
1259
+ def test_search_namespace_segment_boundary(store: SqliteStore) -> None:
1260
+ """Prefix scoping must stop at namespace segment boundaries.
1261
+
1262
+ Namespaces are stored dot-joined, so matching the raw text also returns
1263
+ siblings sharing leading characters.
1264
+ """
1265
+ for namespace in [
1266
+ ("foo",),
1267
+ ("foo", "child"),
1268
+ ("foo", "child", "deep"),
1269
+ ("foobar",),
1270
+ ("foobar", "baz"),
1271
+ ("foo2",),
1272
+ ]:
1273
+ store.put(namespace, "k", {"v": 1})
1274
+
1275
+ def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
1276
+ return {item.namespace for item in store.search(prefix, limit=100)}
1277
+
1278
+ assert _namespaces(("foo",)) == {
1279
+ ("foo",),
1280
+ ("foo", "child"),
1281
+ ("foo", "child", "deep"),
1282
+ }
1283
+ # The sibling scope is independent, not merely narrower.
1284
+ assert _namespaces(("foobar",)) == {("foobar",), ("foobar", "baz")}
1285
+ assert _namespaces(("foo2",)) == {("foo2",)}
1286
+ assert _namespaces(("fo",)) == set()
1287
+
1288
+
1289
+ def test_search_namespace_wildcard_chars_are_literal(store: SqliteStore) -> None:
1290
+ """LIKE and GLOB metacharacters in labels must be matched literally."""
1291
+ for namespace in [
1292
+ ("user_1",),
1293
+ ("user_1", "child"),
1294
+ ("userX1",),
1295
+ ("a%b",),
1296
+ ("axxb",),
1297
+ ("star*",),
1298
+ ("starX",),
1299
+ ]:
1300
+ store.put(namespace, "k", {"v": 1})
1301
+
1302
+ def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
1303
+ return {item.namespace for item in store.search(prefix, limit=100)}
1304
+
1305
+ # Also asserts each namespace still matches itself, which catches escaping
1306
+ # the equality arm by mistake.
1307
+ assert _namespaces(("user_1",)) == {("user_1",), ("user_1", "child")}
1308
+ assert _namespaces(("a%b",)) == {("a%b",)}
1309
+ assert _namespaces(("star*",)) == {("star*",)}
1310
+
1311
+
1312
+ def test_search_namespace_is_case_sensitive(store: SqliteStore) -> None:
1313
+ """Search must agree with get/put, which compare namespaces with `=`.
1314
+
1315
+ SQLite's LIKE is case-insensitive for ASCII, so matching with it conflated
1316
+ namespaces that every other operation treats as distinct.
1317
+ """
1318
+ store.put(("Foo",), "k", {"v": "upper"})
1319
+ store.put(("foo",), "k", {"v": "lower"})
1320
+
1321
+ assert {item.namespace for item in store.search(("foo",), limit=100)} == {("foo",)}
1322
+ assert {item.namespace for item in store.search(("Foo",), limit=100)} == {("Foo",)}
1323
+
1324
+
1325
+ def test_list_namespaces_segment_boundary(store: SqliteStore) -> None:
1326
+ for namespace in [
1327
+ ("foo",),
1328
+ ("foo", "child"),
1329
+ ("foobar",),
1330
+ ("foobar", "baz"),
1331
+ ("uid", "users", "alice"),
1332
+ ("uid", "users", "malice"),
1333
+ ("uid", "a", "b", "alice"),
1334
+ ]:
1335
+ store.put(namespace, "k", {"v": 1})
1336
+
1337
+ assert set(store.list_namespaces(prefix=["foo"], limit=100)) == {
1338
+ ("foo",),
1339
+ ("foo", "child"),
1340
+ }
1341
+ # Suffix must align to a segment: "malice" does not end with the "alice"
1342
+ # segment.
1343
+ assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
1344
+ ("uid", "users", "alice"),
1345
+ ("uid", "a", "b", "alice"),
1346
+ }
1347
+ # "*" spans exactly one segment.
1348
+ assert set(store.list_namespaces(prefix=["uid", "*", "alice"], limit=100)) == {
1349
+ ("uid", "users", "alice"),
1350
+ }
1351
+ # Prefix matching stays open-ended across depth.
1352
+ assert set(store.list_namespaces(prefix=["uid"], limit=100)) == {
1353
+ ("uid", "users", "alice"),
1354
+ ("uid", "users", "malice"),
1355
+ ("uid", "a", "b", "alice"),
1356
+ }
1357
+
1358
+
1359
+ def test_search_empty_prefix_is_unconstrained(store: SqliteStore) -> None:
1360
+ """An empty prefix constrains nothing and must return every namespace."""
1361
+ for namespace in [("a",), ("b", "c"), ("d", "e", "f")]:
1362
+ store.put(namespace, "k", {"v": 1})
1363
+
1364
+ assert {item.namespace for item in store.search((), limit=100)} == {
1365
+ ("a",),
1366
+ ("b", "c"),
1367
+ ("d", "e", "f"),
1368
+ }
1369
+
1370
+
1371
+ def test_namespace_labels_with_trailing_newline(store: SqliteStore) -> None:
1372
+ """Labels may contain newlines, and must not match a differently-named label.
1373
+
1374
+ Python's `$` also matches just before a trailing newline, so the patterns use
1375
+ `\\Z` to anchor at the true end of the string.
1376
+ """
1377
+ store.put(("users", "alice"), "k", {"v": 1})
1378
+ store.put(("users", "alice\n"), "k", {"v": 2})
1379
+
1380
+ assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
1381
+ ("users", "alice"),
1382
+ }
1383
+ assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
1384
+ ("users", "alice"),
1385
+ }
1386
+
1387
+
1388
+ def test_list_namespaces_prefix_uses_indexable_condition() -> None:
1389
+ """Plain prefixes must use the indexable condition, not the match function.
1390
+
1391
+ A user function is opaque to the query planner, so it scans every row and
1392
+ calls back into Python for each one. Only suffix and wildcard paths, which
1393
+ no SQLite operator can express, need it.
1394
+ """
1395
+ store = BaseSqliteStore()
1396
+
1397
+ def where(match_type: str, path: tuple[str, ...]) -> str:
1398
+ op = ListNamespacesOp(
1399
+ match_conditions=(MatchCondition(match_type=match_type, path=path),),
1400
+ max_depth=None,
1401
+ limit=10,
1402
+ offset=0,
1403
+ )
1404
+ query, _ = store._get_batch_list_namespaces_queries([(0, op)])[0]
1405
+ return " ".join(query.split())
1406
+
1407
+ assert "GLOB" in where("prefix", ("uid", "users"))
1408
+ assert NS_MATCH_FUNCTION not in where("prefix", ("uid", "users"))
1409
+ # A label that merely contains "*" is not the wildcard.
1410
+ assert "GLOB" in where("prefix", ("star*",))
1411
+ # Wildcard and suffix cannot be expressed by GLOB, so they keep the function.
1412
+ assert NS_MATCH_FUNCTION in where("prefix", ("uid", "*", "alice"))
1413
+ assert NS_MATCH_FUNCTION in where("suffix", ("alice",))
1414
+
1415
+
1416
+ def test_list_namespaces_metacharacter_labels(store: SqliteStore) -> None:
1417
+ """Metacharacters in labels are literal on both matching paths.
1418
+
1419
+ Plain prefixes take the `= OR GLOB` condition and wildcard/suffix paths take
1420
+ the regex function, so escaping has to hold in two different syntaxes.
1421
+ """
1422
+ pairs = [
1423
+ ("star*", "starX"),
1424
+ ("q?m", "qXm"),
1425
+ ("br[ack]et", "brXacXket"),
1426
+ ("user_1", "userX1"),
1427
+ ("a%b", "axxb"),
1428
+ ("plus+", "plusX"),
1429
+ ]
1430
+ for label, decoy in pairs:
1431
+ store.put((label,), "k", {"v": 1})
1432
+ store.put((decoy,), "k", {"v": 1})
1433
+ store.put((label, "child"), "k", {"v": 1})
1434
+
1435
+ for label, decoy in pairs:
1436
+ found = set(store.list_namespaces(prefix=[label], limit=100))
1437
+ assert found == {(label,), (label, "child")}
1438
+ # The decoy differs only where the metacharacter would have matched.
1439
+ assert (decoy,) not in found
1440
+ assert set(store.list_namespaces(prefix=[label, "child"], limit=100)) == {
1441
+ (label, "child"),
1442
+ }