diff --git a/__snapshots__/test_parser.ambr b/__snapshots__/test_parser.ambr index b29ec59..602188d 100644 --- a/__snapshots__/test_parser.ambr +++ b/__snapshots__/test_parser.ambr @@ -1,4 +1,37 @@ # serializer version: 1 +# name: test_notion + dict({ + 'parent': dict({ + 'database_id': 'PARENT_PAGE', + }), + 'properties': dict({ + 'id': dict({ + 'number': True, + }), + 'title': dict({ + 'title': dict({ + }), + }), + }), + 'title': list([ + dict({ + 'text': dict({ + 'content': 'table1', + }), + }), + ]), + }) +# --- +# name: test_sql_parser[CREATE TABLE IF NOT EXISTS table1 (title title, id int);] + dict({ + 'columns': dict({ + 'id': 'INT', + 'title': 'title', + }), + 'exists': True, + 'table_name': 'table1', + }) +# --- # name: test_sql_parser[DELETE FROM table1 WHERE column1 = 'value1';] dict({ 'table_name': 'table1', diff --git a/pynotiondb/mysql_query_parser.py b/pynotiondb/mysql_query_parser.py index 8ac51f9..1e40891 100644 --- a/pynotiondb/mysql_query_parser.py +++ b/pynotiondb/mysql_query_parser.py @@ -3,6 +3,7 @@ EQ, And, Binary, + Create, Delete, Expression, Insert, @@ -128,6 +129,23 @@ def extract_delete_statement_info(self) -> dict: return {"table_name": table_name, "where_clause": where_clause} + def extract_create_statement_info(self) -> dict: + match: Create = self.statement + assert match.kind == "TABLE" + schema = match.this + + table_name = schema.this.text("this") + columns = { + col.text("this"): col.kind.args.get("kind", col.kind.this.value) + for col in schema.expressions + } + + return { + "table_name": table_name, + "columns": columns, + "exists": match.args.get("exists", False), + } + def extract_set_values(self, set_values_str: list[EQ]) -> list[dict]: set_values = [] # Split by 'AND', but not within quotes @@ -153,6 +171,9 @@ def parse(self) -> dict: if isinstance(self.statement, Delete): return self.extract_delete_statement_info() + if isinstance(self.statement, Create): + return self.extract_create_statement_info() + raise ValueError("Invalid SQL statement") def check_statement(self) -> tuple[bool, str]: @@ -164,5 +185,7 @@ def check_statement(self) -> tuple[bool, str]: return True, "update" if isinstance(self.statement, Delete): return True, "delete" + elif isinstance(self.statement, Create): + return True, "create" return False, "unknown" diff --git a/pynotiondb/notion_api.py b/pynotiondb/notion_api.py index 7d64adb..98a6804 100644 --- a/pynotiondb/notion_api.py +++ b/pynotiondb/notion_api.py @@ -1,6 +1,9 @@ import logging +from typing import Optional +from functools import lru_cache import requests +from notion_client import Client from .exceptions import NotionAPIError from .mysql_query_parser import MySQLQueryParser @@ -8,6 +11,17 @@ logger = logging.getLogger(__name__) +def format_type(s: str) -> dict: + if s == "INT": + return {"number": True} + elif s == "VARCHAR": + return {"rich_text": {}} + elif s == "title": + return {"title": {}} + else: + raise Exception(s) + + class NotionAPI: SEARCH = "https://api.notion.com/v1/search" PAGES = "https://api.notion.com/v1/pages" @@ -17,7 +31,7 @@ class NotionAPI: QUERY_DATABASE = "https://api.notion.com/v1/databases/{}/query" DEFAULT_PAGE_SIZE_FOR_SELECT_STATEMENTS = 20 token: str - databases: dict[str, str] + table_parent_page: Optional[str] CONDITION_MAPPING = { "EQ": "equals", @@ -27,9 +41,14 @@ class NotionAPI: ">=": "greater_than_or_equal_to", } - def __init__(self, token: str, databases: dict[str, str]) -> None: + def __init__( + self, + token: str, + *, + table_parent_page: Optional[str] = None, + ) -> None: self.token = token - self.databases = databases + self.table_parent_page = table_parent_page self.DEFAULT_NOTION_VERSION = "2022-06-28" self.AUTHORIZATION = "Bearer " + self.token self.headers = { @@ -39,6 +58,7 @@ def __init__(self, token: str, databases: dict[str, str]) -> None: } self.session = requests.Session() self.session.headers.update(self.headers) + self.client = Client(auth=token) def request_helper(self, url: str, method: str = "GET", payload=None): response = self.session.request(method, url, json=payload) @@ -162,6 +182,11 @@ def get_all_database_info(self, cursor=None, page_size=20): return data + @property + @lru_cache() + def databases(self): + return {db["title"]: db["id"] for db in self.get_all_database_info()["results"]} + def get_all_database(self): dbs = self.get_all_database_info() databases = [db.get("title") for db in dbs.get("results")] @@ -419,6 +444,17 @@ def update(self, query) -> None: payload=payload, ) + def create(self, query: str) -> None: + if not self.table_parent_page: + raise Exception("Parent for new tables must be specified") + parsed_data = MySQLQueryParser(query).parse() + props = {col: format_type(typ) for col, typ in parsed_data["columns"].items()} + return self.client.databases.create( + title=[{"text": {"content": parsed_data["table_name"]}}], + parent={"database_id": self.table_parent_page}, + properties=props, + ) + def delete(self, query) -> None: parsed_data = MySQLQueryParser(query).parse() @@ -468,6 +504,9 @@ def execute(self, sql, val=None): elif to_do == "delete": self.delete(query) + elif to_do == "create": + return self.create(query) + else: raise ValueError("Unsupported operation") @@ -475,3 +514,4 @@ def execute(self, sql, val=None): raise ValueError( "Invalid SQL statement or type of statement not implemented" ) + diff --git a/pyproject.toml b/pyproject.toml index 7744a46..c3cdb1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,9 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ + "notion-client>=2.5.0", "requests>=2.0.0", + "respx>=0.22.0", "sqlglot>=18.2.0", ] diff --git a/test_parser.py b/test_parser.py index 8e757f5..198f293 100644 --- a/test_parser.py +++ b/test_parser.py @@ -1,7 +1,13 @@ +import json + from pytest import mark +from respx import MockRouter +from pynotiondb import NotionAPI from pynotiondb.mysql_query_parser import MySQLQueryParser +create_sql = "CREATE TABLE IF NOT EXISTS table1 (title title, id int);" + @mark.parametrize( "sql", @@ -13,6 +19,7 @@ "DELETE FROM table1 WHERE column1 = 'value1';", "SELECT * FROM table1 WHERE column1=1 AND column2='text' OR column3 IS NULL;", "SELECT *, agg_list(column) FROM table GROUP BY column2 LIMIT 10 OFFSET 5;", + create_sql, ], ) def test_sql_parser(sql: str, snapshot): @@ -21,3 +28,13 @@ def test_sql_parser(sql: str, snapshot): assert ok snapshot.assert_match(parser.parse()) + + +def test_notion(snapshot): + with MockRouter(base_url="https://api.notion.com/v1") as req: + call = req.post("/databases").respond(200, json={}) + notion = NotionAPI("", table_parent_page="PARENT_PAGE") + notion.execute(create_sql) + + assert call.called + assert snapshot == json.loads(req.calls.last.request.content) diff --git a/uv.lock b/uv.lock index 51a5334..28fdaa3 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,43 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] +[[package]] +name = "anyio" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.8.1' and python_full_version < '3.9'", + "python_full_version < '3.8.1'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "idna", marker = "python_full_version < '3.9'" }, + { name = "sniffio", marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.9'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "idna", marker = "python_full_version >= '3.9'" }, + { name = "sniffio", marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -143,6 +180,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/c3/6f0e3896f193528bbd2b4d2122d4be8108a37efab0b8475855556a8c4afa/fancycompleter-0.11.1-py3-none-any.whl", hash = "sha256:44243d7fab37087208ca5acacf8f74c0aa4d733d04d593857873af7513cdf8a6", size = 11207, upload-time = "2025-05-26T12:59:09.857Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "anyio", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -161,6 +236,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "notion-client" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/8c/b2f11904e1ef7a33338d3890fc50d30ffa2d0fca51b01b0a827d909b615a/notion_client-2.5.0.tar.gz", hash = "sha256:a03a0cc0502292193527af07a3bd13e40eb2b1083b60dcc7d733baad2d2729fd", size = 20865, upload-time = "2025-08-26T10:40:05.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/52/6b2a3081e1cb1123b1b28d5b1ebe88cb9e19b72264552dc707c1b265a1a7/notion_client-2.5.0-py2.py3-none-any.whl", hash = "sha256:d2efde1def1e7a3cc68cf4d2cab715571c71767852b26bf04474e9945d4a5903", size = 14348, upload-time = "2025-08-26T10:40:03.806Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -222,8 +309,10 @@ name = "pynotiondb" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "notion-client" }, { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "respx" }, { name = "sqlglot", version = "26.33.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "sqlglot", version = "27.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] @@ -240,7 +329,9 @@ dev = [ [package.metadata] requires-dist = [ + { name = "notion-client", specifier = ">=2.5.0" }, { name = "requests", specifier = ">=2.0.0" }, + { name = "respx", specifier = ">=0.22.0" }, { name = "sqlglot", specifier = ">=18.2.0" }, ] @@ -367,6 +458,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlglot" version = "26.33.0"