langgraph-checkpoint-sqlite 3.0.0__tar.gz → 3.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langgraph_checkpoint_sqlite-3.0.2/.gitignore +102 -0
- langgraph_checkpoint_sqlite-3.0.2/LICENSE +21 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/PKG-INFO +7 -3
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/checkpoint/sqlite/__init__.py +3 -2
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/checkpoint/sqlite/aio.py +3 -2
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/checkpoint/sqlite/utils.py +21 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/store/sqlite/base.py +62 -96
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/pyproject.toml +6 -3
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_aiosqlite.py +75 -1
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_sqlite.py +125 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_store.py +135 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/uv.lock +52 -52
- langgraph_checkpoint_sqlite-3.0.0/.gitignore +0 -184
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/Makefile +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/README.md +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/cache/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/checkpoint/sqlite/py.typed +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/store/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/langgraph/store/sqlite/aio.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_async_store.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_ttl.py +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
.vs/
|
|
2
|
+
.vscode/
|
|
3
|
+
.idea/
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# Django stuff:
|
|
55
|
+
*.log
|
|
56
|
+
local_settings.py
|
|
57
|
+
db.sqlite3
|
|
58
|
+
db.sqlite3-journal
|
|
59
|
+
|
|
60
|
+
# PyBuilder
|
|
61
|
+
target/
|
|
62
|
+
|
|
63
|
+
# Jupyter Notebook
|
|
64
|
+
.ipynb_checkpoints
|
|
65
|
+
notebooks/
|
|
66
|
+
|
|
67
|
+
# IPython
|
|
68
|
+
profile_default/
|
|
69
|
+
ipython_config.py
|
|
70
|
+
|
|
71
|
+
# pyenv
|
|
72
|
+
.python-version
|
|
73
|
+
|
|
74
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
75
|
+
__pypackages__/
|
|
76
|
+
|
|
77
|
+
# Environments
|
|
78
|
+
.env
|
|
79
|
+
.envrc
|
|
80
|
+
.venv
|
|
81
|
+
.venvs
|
|
82
|
+
env/
|
|
83
|
+
venv/
|
|
84
|
+
ENV/
|
|
85
|
+
env.bak/
|
|
86
|
+
venv.bak/
|
|
87
|
+
|
|
88
|
+
# mypy
|
|
89
|
+
.mypy_cache/
|
|
90
|
+
.dmypy.json
|
|
91
|
+
dmypy.json
|
|
92
|
+
|
|
93
|
+
# Pyre type checker
|
|
94
|
+
.pyre/
|
|
95
|
+
|
|
96
|
+
# macOS display setting files
|
|
97
|
+
.DS_Store
|
|
98
|
+
|
|
99
|
+
.vercel
|
|
100
|
+
.turbo
|
|
101
|
+
.editorconfig
|
|
102
|
+
.scratch
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 LangChain, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-sqlite
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
|
|
5
|
-
Project-URL:
|
|
5
|
+
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
|
|
6
|
+
Project-URL: Twitter, https://x.com/LangChainAI
|
|
7
|
+
Project-URL: Slack, https://www.langchain.com/join-community
|
|
8
|
+
Project-URL: Reddit, https://www.reddit.com/r/LangChain/
|
|
6
9
|
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
7
11
|
Requires-Python: >=3.10
|
|
8
12
|
Requires-Dist: aiosqlite>=0.20
|
|
9
|
-
Requires-Dist: langgraph-checkpoint<
|
|
13
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=3
|
|
10
14
|
Requires-Dist: sqlite-vec>=0.1.6
|
|
11
15
|
Description-Content-Type: text/markdown
|
|
12
16
|
|
|
@@ -329,8 +329,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
329
329
|
FROM checkpoints
|
|
330
330
|
{where}
|
|
331
331
|
ORDER BY checkpoint_id DESC"""
|
|
332
|
-
if limit:
|
|
333
|
-
query +=
|
|
332
|
+
if limit is not None:
|
|
333
|
+
query += " LIMIT ?"
|
|
334
|
+
param_values = (*param_values, limit)
|
|
334
335
|
with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur:
|
|
335
336
|
cur.execute(query, param_values)
|
|
336
337
|
for (
|
|
@@ -425,8 +425,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
|
|
425
425
|
FROM checkpoints
|
|
426
426
|
{where}
|
|
427
427
|
ORDER BY checkpoint_id DESC"""
|
|
428
|
-
if limit:
|
|
429
|
-
query +=
|
|
428
|
+
if limit is not None:
|
|
429
|
+
query += " LIMIT ?"
|
|
430
|
+
params = (*params, limit)
|
|
430
431
|
async with (
|
|
431
432
|
self.lock,
|
|
432
433
|
self.conn.execute(query, params) as cur,
|
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
from collections.abc import Sequence
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from langchain_core.runnables import RunnableConfig
|
|
8
9
|
from langgraph.checkpoint.base import get_checkpoint_id
|
|
9
10
|
|
|
11
|
+
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_filter_key(key: str) -> None:
|
|
15
|
+
"""Validate that a filter key is safe for use in SQL queries.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
key: The filter key to validate
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If the key contains invalid characters that could enable SQL injection
|
|
22
|
+
"""
|
|
23
|
+
# Allow alphanumeric characters, underscores, dots, and hyphens
|
|
24
|
+
# This covers typical JSON property names while preventing SQL injection
|
|
25
|
+
if not _FILTER_PATTERN.match(key):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
|
|
28
|
+
)
|
|
29
|
+
|
|
10
30
|
|
|
11
31
|
def _metadata_predicate(
|
|
12
32
|
metadata_filter: dict[str, Any],
|
|
@@ -43,6 +63,7 @@ def _metadata_predicate(
|
|
|
43
63
|
|
|
44
64
|
# process metadata query
|
|
45
65
|
for query_key, query_value in metadata_filter.items():
|
|
66
|
+
_validate_filter_key(query_key)
|
|
46
67
|
operator, param_value = _where_value(query_value)
|
|
47
68
|
predicates.append(
|
|
48
69
|
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
|
|
@@ -107,6 +107,9 @@ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
|
|
|
107
107
|
return tuple(namespace.split("."))
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
|
111
|
+
|
|
112
|
+
|
|
110
113
|
def _validate_filter_key(key: str) -> None:
|
|
111
114
|
"""Validate that a filter key is safe for use in SQL queries.
|
|
112
115
|
|
|
@@ -118,7 +121,7 @@ def _validate_filter_key(key: str) -> None:
|
|
|
118
121
|
"""
|
|
119
122
|
# Allow alphanumeric characters, underscores, dots, and hyphens
|
|
120
123
|
# This covers typical JSON property names while preventing SQL injection
|
|
121
|
-
if not
|
|
124
|
+
if not _FILTER_PATTERN.match(key):
|
|
122
125
|
raise ValueError(
|
|
123
126
|
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
|
|
124
127
|
)
|
|
@@ -404,12 +407,9 @@ class BaseSqliteStore:
|
|
|
404
407
|
# SQLite json_extract returns unquoted string values
|
|
405
408
|
if isinstance(value, str):
|
|
406
409
|
filter_conditions.append(
|
|
407
|
-
"json_extract(value, '$."
|
|
408
|
-
+ key
|
|
409
|
-
+ "') = '"
|
|
410
|
-
+ value.replace("'", "''")
|
|
411
|
-
+ "'"
|
|
410
|
+
"json_extract(value, '$." + key + "') = ?"
|
|
412
411
|
)
|
|
412
|
+
filter_params.append(value)
|
|
413
413
|
elif value is None:
|
|
414
414
|
filter_conditions.append(
|
|
415
415
|
"json_extract(value, '$." + key + "') IS NULL"
|
|
@@ -423,9 +423,11 @@ class BaseSqliteStore:
|
|
|
423
423
|
+ ("1" if value else "0")
|
|
424
424
|
)
|
|
425
425
|
elif isinstance(value, (int, float)):
|
|
426
|
+
# Use parameterized query to handle special floats and large integers
|
|
426
427
|
filter_conditions.append(
|
|
427
|
-
"json_extract(value, '$." + key + "') = "
|
|
428
|
+
"json_extract(value, '$." + key + "') = ?"
|
|
428
429
|
)
|
|
430
|
+
filter_params.append(float(value))
|
|
429
431
|
else:
|
|
430
432
|
# Complex objects (list, dict, …) – compare JSON text
|
|
431
433
|
filter_conditions.append(
|
|
@@ -636,85 +638,66 @@ class BaseSqliteStore:
|
|
|
636
638
|
# We need to properly format values for SQLite JSON extraction comparison
|
|
637
639
|
if op == "$eq":
|
|
638
640
|
if isinstance(value, str):
|
|
639
|
-
|
|
640
|
-
return (
|
|
641
|
-
f"json_extract(value, '$.{key}') = '"
|
|
642
|
-
+ value.replace("'", "''")
|
|
643
|
-
+ "'",
|
|
644
|
-
[],
|
|
645
|
-
)
|
|
641
|
+
return f"json_extract(value, '$.{key}') = ?", [value]
|
|
646
642
|
elif value is None:
|
|
647
643
|
return f"json_extract(value, '$.{key}') IS NULL", []
|
|
648
644
|
elif isinstance(value, bool):
|
|
649
645
|
# SQLite JSON stores booleans as integers
|
|
650
646
|
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
|
651
647
|
elif isinstance(value, (int, float)):
|
|
652
|
-
|
|
648
|
+
# Convert to float to handle inf, -inf, nan, and very large integers
|
|
649
|
+
# SQLite REAL can handle these cases better than INTEGER
|
|
650
|
+
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
|
653
651
|
else:
|
|
654
652
|
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
|
655
653
|
elif op == "$gt":
|
|
656
654
|
# For numeric values, SQLite needs to compare as numbers, not strings
|
|
657
655
|
if isinstance(value, (int, float)):
|
|
658
|
-
|
|
656
|
+
# Convert to float to handle special values and very large integers
|
|
657
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
|
658
|
+
float(value)
|
|
659
|
+
]
|
|
659
660
|
elif isinstance(value, str):
|
|
660
|
-
return (
|
|
661
|
-
f"json_extract(value, '$.{key}') > '"
|
|
662
|
-
+ value.replace("'", "''")
|
|
663
|
-
+ "'",
|
|
664
|
-
[],
|
|
665
|
-
)
|
|
661
|
+
return f"json_extract(value, '$.{key}') > ?", [value]
|
|
666
662
|
else:
|
|
667
663
|
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
|
668
664
|
elif op == "$gte":
|
|
669
665
|
if isinstance(value, (int, float)):
|
|
670
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) >=
|
|
666
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
|
667
|
+
float(value)
|
|
668
|
+
]
|
|
671
669
|
elif isinstance(value, str):
|
|
672
|
-
return (
|
|
673
|
-
f"json_extract(value, '$.{key}') >= '"
|
|
674
|
-
+ value.replace("'", "''")
|
|
675
|
-
+ "'",
|
|
676
|
-
[],
|
|
677
|
-
)
|
|
670
|
+
return f"json_extract(value, '$.{key}') >= ?", [value]
|
|
678
671
|
else:
|
|
679
672
|
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
|
680
673
|
elif op == "$lt":
|
|
681
674
|
if isinstance(value, (int, float)):
|
|
682
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) <
|
|
675
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
|
676
|
+
float(value)
|
|
677
|
+
]
|
|
683
678
|
elif isinstance(value, str):
|
|
684
|
-
return (
|
|
685
|
-
f"json_extract(value, '$.{key}') < '"
|
|
686
|
-
+ value.replace("'", "''")
|
|
687
|
-
+ "'",
|
|
688
|
-
[],
|
|
689
|
-
)
|
|
679
|
+
return f"json_extract(value, '$.{key}') < ?", [value]
|
|
690
680
|
else:
|
|
691
681
|
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
|
692
682
|
elif op == "$lte":
|
|
693
683
|
if isinstance(value, (int, float)):
|
|
694
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) <=
|
|
684
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
|
685
|
+
float(value)
|
|
686
|
+
]
|
|
695
687
|
elif isinstance(value, str):
|
|
696
|
-
return (
|
|
697
|
-
f"json_extract(value, '$.{key}') <= '"
|
|
698
|
-
+ value.replace("'", "''")
|
|
699
|
-
+ "'",
|
|
700
|
-
[],
|
|
701
|
-
)
|
|
688
|
+
return f"json_extract(value, '$.{key}') <= ?", [value]
|
|
702
689
|
else:
|
|
703
690
|
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
|
704
691
|
elif op == "$ne":
|
|
705
692
|
if isinstance(value, str):
|
|
706
|
-
return (
|
|
707
|
-
f"json_extract(value, '$.{key}') != '"
|
|
708
|
-
+ value.replace("'", "''")
|
|
709
|
-
+ "'",
|
|
710
|
-
[],
|
|
711
|
-
)
|
|
693
|
+
return f"json_extract(value, '$.{key}') != ?", [value]
|
|
712
694
|
elif value is None:
|
|
713
695
|
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
|
714
696
|
elif isinstance(value, bool):
|
|
715
697
|
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
|
716
698
|
elif isinstance(value, (int, float)):
|
|
717
|
-
|
|
699
|
+
# Convert to float for consistency
|
|
700
|
+
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
|
718
701
|
else:
|
|
719
702
|
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
|
720
703
|
else:
|
|
@@ -739,7 +722,8 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|
|
739
722
|
item = store.get(("users", "123"), "prefs")
|
|
740
723
|
```
|
|
741
724
|
|
|
742
|
-
Or using the convenient from_conn_string helper:
|
|
725
|
+
Or using the convenient `from_conn_string` helper:
|
|
726
|
+
|
|
743
727
|
```python
|
|
744
728
|
from langgraph.store.sqlite import SqliteStore
|
|
745
729
|
|
|
@@ -792,8 +776,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|
|
792
776
|
self,
|
|
793
777
|
conn: sqlite3.Connection,
|
|
794
778
|
*,
|
|
795
|
-
deserializer:
|
|
796
|
-
|
|
779
|
+
deserializer: (
|
|
780
|
+
Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None
|
|
781
|
+
) = None,
|
|
797
782
|
index: SqliteIndexConfig | None = None,
|
|
798
783
|
ttl: TTLConfig | None = None,
|
|
799
784
|
):
|
|
@@ -874,85 +859,66 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|
|
874
859
|
# We need to properly format values for SQLite JSON extraction comparison
|
|
875
860
|
if op == "$eq":
|
|
876
861
|
if isinstance(value, str):
|
|
877
|
-
|
|
878
|
-
return (
|
|
879
|
-
f"json_extract(value, '$.{key}') = '"
|
|
880
|
-
+ value.replace("'", "''")
|
|
881
|
-
+ "'",
|
|
882
|
-
[],
|
|
883
|
-
)
|
|
862
|
+
return f"json_extract(value, '$.{key}') = ?", [value]
|
|
884
863
|
elif value is None:
|
|
885
864
|
return f"json_extract(value, '$.{key}') IS NULL", []
|
|
886
865
|
elif isinstance(value, bool):
|
|
887
866
|
# SQLite JSON stores booleans as integers
|
|
888
867
|
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
|
889
868
|
elif isinstance(value, (int, float)):
|
|
890
|
-
|
|
869
|
+
# Convert to float to handle inf, -inf, nan, and very large integers
|
|
870
|
+
# SQLite REAL can handle these cases better than INTEGER
|
|
871
|
+
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
|
891
872
|
else:
|
|
892
873
|
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
|
893
874
|
elif op == "$gt":
|
|
894
875
|
# For numeric values, SQLite needs to compare as numbers, not strings
|
|
895
876
|
if isinstance(value, (int, float)):
|
|
896
|
-
|
|
877
|
+
# Convert to float to handle special values and very large integers
|
|
878
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
|
879
|
+
float(value)
|
|
880
|
+
]
|
|
897
881
|
elif isinstance(value, str):
|
|
898
|
-
return (
|
|
899
|
-
f"json_extract(value, '$.{key}') > '"
|
|
900
|
-
+ value.replace("'", "''")
|
|
901
|
-
+ "'",
|
|
902
|
-
[],
|
|
903
|
-
)
|
|
882
|
+
return f"json_extract(value, '$.{key}') > ?", [value]
|
|
904
883
|
else:
|
|
905
884
|
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
|
906
885
|
elif op == "$gte":
|
|
907
886
|
if isinstance(value, (int, float)):
|
|
908
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) >=
|
|
887
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
|
888
|
+
float(value)
|
|
889
|
+
]
|
|
909
890
|
elif isinstance(value, str):
|
|
910
|
-
return (
|
|
911
|
-
f"json_extract(value, '$.{key}') >= '"
|
|
912
|
-
+ value.replace("'", "''")
|
|
913
|
-
+ "'",
|
|
914
|
-
[],
|
|
915
|
-
)
|
|
891
|
+
return f"json_extract(value, '$.{key}') >= ?", [value]
|
|
916
892
|
else:
|
|
917
893
|
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
|
918
894
|
elif op == "$lt":
|
|
919
895
|
if isinstance(value, (int, float)):
|
|
920
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) <
|
|
896
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
|
897
|
+
float(value)
|
|
898
|
+
]
|
|
921
899
|
elif isinstance(value, str):
|
|
922
|
-
return (
|
|
923
|
-
f"json_extract(value, '$.{key}') < '"
|
|
924
|
-
+ value.replace("'", "''")
|
|
925
|
-
+ "'",
|
|
926
|
-
[],
|
|
927
|
-
)
|
|
900
|
+
return f"json_extract(value, '$.{key}') < ?", [value]
|
|
928
901
|
else:
|
|
929
902
|
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
|
930
903
|
elif op == "$lte":
|
|
931
904
|
if isinstance(value, (int, float)):
|
|
932
|
-
return f"CAST(json_extract(value, '$.{key}') AS REAL) <=
|
|
905
|
+
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
|
906
|
+
float(value)
|
|
907
|
+
]
|
|
933
908
|
elif isinstance(value, str):
|
|
934
|
-
return (
|
|
935
|
-
f"json_extract(value, '$.{key}') <= '"
|
|
936
|
-
+ value.replace("'", "''")
|
|
937
|
-
+ "'",
|
|
938
|
-
[],
|
|
939
|
-
)
|
|
909
|
+
return f"json_extract(value, '$.{key}') <= ?", [value]
|
|
940
910
|
else:
|
|
941
911
|
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
|
942
912
|
elif op == "$ne":
|
|
943
913
|
if isinstance(value, str):
|
|
944
|
-
return (
|
|
945
|
-
f"json_extract(value, '$.{key}') != '"
|
|
946
|
-
+ value.replace("'", "''")
|
|
947
|
-
+ "'",
|
|
948
|
-
[],
|
|
949
|
-
)
|
|
914
|
+
return f"json_extract(value, '$.{key}') != ?", [value]
|
|
950
915
|
elif value is None:
|
|
951
916
|
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
|
952
917
|
elif isinstance(value, bool):
|
|
953
918
|
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
|
954
919
|
elif isinstance(value, (int, float)):
|
|
955
|
-
|
|
920
|
+
# Convert to float for consistency
|
|
921
|
+
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
|
956
922
|
else:
|
|
957
923
|
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
|
958
924
|
else:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langgraph-checkpoint-sqlite"
|
|
7
|
-
version = "3.0.
|
|
7
|
+
version = "3.0.2"
|
|
8
8
|
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,13 +12,16 @@ readme = "README.md"
|
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ['LICENSE']
|
|
14
14
|
dependencies = [
|
|
15
|
-
"langgraph-checkpoint>=3,<
|
|
15
|
+
"langgraph-checkpoint>=3,<5.0.0",
|
|
16
16
|
"aiosqlite>=0.20",
|
|
17
17
|
"sqlite-vec>=0.1.6",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.urls]
|
|
21
|
-
|
|
21
|
+
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
|
|
22
|
+
Twitter = "https://x.com/LangChainAI"
|
|
23
|
+
Slack = "https://www.langchain.com/join-community"
|
|
24
|
+
Reddit = "https://www.reddit.com/r/LangChain/"
|
|
22
25
|
|
|
23
26
|
[dependency-groups]
|
|
24
27
|
test = [
|
{langgraph_checkpoint_sqlite-3.0.0 → langgraph_checkpoint_sqlite-3.0.2}/tests/test_aiosqlite.py
RENAMED
|
@@ -113,4 +113,78 @@ class TestAsyncSqliteSaver:
|
|
|
113
113
|
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
|
114
114
|
} == {"", "inner"}
|
|
115
115
|
|
|
116
|
-
#
|
|
116
|
+
# Test limit param
|
|
117
|
+
search_results_6 = [
|
|
118
|
+
c
|
|
119
|
+
async for c in saver.alist(
|
|
120
|
+
{"configurable": {"thread_id": "thread-2"}}, limit=1
|
|
121
|
+
)
|
|
122
|
+
]
|
|
123
|
+
assert len(search_results_6) == 1
|
|
124
|
+
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2"
|
|
125
|
+
|
|
126
|
+
# Test before param
|
|
127
|
+
search_results_7 = [
|
|
128
|
+
c async for c in saver.alist(None, before=search_results_5[1].config)
|
|
129
|
+
]
|
|
130
|
+
assert len(search_results_7) == 1
|
|
131
|
+
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1"
|
|
132
|
+
|
|
133
|
+
async def test_limit_parameter_sql_injection_prevention(self) -> None:
|
|
134
|
+
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
|
|
135
|
+
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
136
|
+
# Setup: Create multiple checkpoints
|
|
137
|
+
for i in range(5):
|
|
138
|
+
config: RunnableConfig = {
|
|
139
|
+
"configurable": {
|
|
140
|
+
"thread_id": f"thread-{i}",
|
|
141
|
+
"checkpoint_ns": "",
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
checkpoint = empty_checkpoint()
|
|
145
|
+
metadata: CheckpointMetadata = {"index": i}
|
|
146
|
+
await saver.aput(config, checkpoint, metadata, {})
|
|
147
|
+
|
|
148
|
+
# Test that limit works correctly with valid integer
|
|
149
|
+
results = [c async for c in saver.alist(None, limit=2)]
|
|
150
|
+
assert len(results) == 2
|
|
151
|
+
|
|
152
|
+
# Test that limit=0 returns no results
|
|
153
|
+
results = [c async for c in saver.alist(None, limit=0)]
|
|
154
|
+
assert len(results) == 0
|
|
155
|
+
|
|
156
|
+
# Test that limit=None returns all results
|
|
157
|
+
results = [c async for c in saver.alist(None, limit=None)]
|
|
158
|
+
assert len(results) == 5
|
|
159
|
+
|
|
160
|
+
# Test explicit SQL injection attempt via limit parameter
|
|
161
|
+
# Even if type checking is bypassed and a malicious string is passed,
|
|
162
|
+
# the parameterized query will treat it as a value, not SQL code
|
|
163
|
+
# This would cause an error (can't convert string to int for LIMIT),
|
|
164
|
+
# which is the correct secure behavior
|
|
165
|
+
malicious_limits = [
|
|
166
|
+
"1; DROP TABLE checkpoints; --",
|
|
167
|
+
"1 OR 1=1",
|
|
168
|
+
"999999 UNION SELECT * FROM checkpoints",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for malicious_limit in malicious_limits:
|
|
172
|
+
# The parameterized query should safely reject non-integer limits
|
|
173
|
+
# or convert them in a way that prevents SQL injection
|
|
174
|
+
try:
|
|
175
|
+
# Bypass type checking by casting
|
|
176
|
+
results = [
|
|
177
|
+
c
|
|
178
|
+
async for c in saver.alist(None, limit=malicious_limit) # type: ignore
|
|
179
|
+
]
|
|
180
|
+
# If it doesn't raise an error, it should at least not execute the injection
|
|
181
|
+
# SQLite's parameter binding will try to convert the string to an integer
|
|
182
|
+
# which will either fail or treat it as 0
|
|
183
|
+
except Exception:
|
|
184
|
+
# Expected: SQLite should reject invalid limit values
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Verify the checkpoints table still exists and has all data
|
|
188
|
+
# (would have been dropped if injection succeeded)
|
|
189
|
+
results = [c async for c in saver.alist(None, limit=None)]
|
|
190
|
+
assert len(results) == 5
|