jsonql-py 0.1.0__py3-none-any.whl

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.
jsonql/__init__.py ADDED
@@ -0,0 +1,142 @@
1
+ """JSONQL Python SDK — A JSON-based query language for SQL databases."""
2
+
3
+ from .builder import MutationBuilder, QueryBuilder
4
+ from .conditions import (
5
+ and_,
6
+ contains,
7
+ ends_with,
8
+ eq,
9
+ field,
10
+ gt,
11
+ gte,
12
+ is_in,
13
+ like,
14
+ lt,
15
+ lte,
16
+ neq,
17
+ not_,
18
+ not_in,
19
+ or_,
20
+ starts_with,
21
+ )
22
+ from .dialect import (
23
+ MySQLDialect,
24
+ PostgresDialect,
25
+ SQLDialect,
26
+ SQLiteDialect,
27
+ new_dialect,
28
+ )
29
+ from .driver import DatabaseDriver
30
+ from .engine import JsonQLEngine
31
+ from .errors import (
32
+ AdapterError,
33
+ JsonQLError,
34
+ JsonQLExecutionError,
35
+ JsonQLTranspileError,
36
+ JsonQLValidationError,
37
+ )
38
+ from .factory import (
39
+ connect_mongo,
40
+ create_driver,
41
+ create_driver_with_dsn,
42
+ env_or,
43
+ load_schema,
44
+ must_connect_mongo,
45
+ must_load_schema,
46
+ )
47
+ from .hydrator import ResultHydrator
48
+ from .logger import ConsoleLogger, Logger, NoOpLogger
49
+ from .mongo_driver import MongoDBDriver
50
+ from .mongo_transpiler import MongoResult, MongoTranspiler
51
+ from .parser import Parser, ParserOptions
52
+ from .transpiler import SQLTranspiler
53
+ from .types import (
54
+ DistinctOption,
55
+ JsonQLField,
56
+ JsonQLMutation,
57
+ JsonQLQuery,
58
+ JsonQLRelation,
59
+ JsonQLSchema,
60
+ JsonQLSettings,
61
+ JsonQLTable,
62
+ TranspileResult,
63
+ ValidationError,
64
+ ValidationResult,
65
+ parse_schema,
66
+ )
67
+ from .validator import Validator
68
+
69
+ __all__ = [
70
+ # Types
71
+ "JsonQLQuery",
72
+ "JsonQLMutation",
73
+ "DistinctOption",
74
+ "JsonQLSchema",
75
+ "JsonQLSettings",
76
+ "JsonQLTable",
77
+ "JsonQLField",
78
+ "JsonQLRelation",
79
+ "TranspileResult",
80
+ "ValidationError",
81
+ "ValidationResult",
82
+ "parse_schema",
83
+ # Core
84
+ "Parser",
85
+ "ParserOptions",
86
+ "SQLTranspiler",
87
+ "Validator",
88
+ "ResultHydrator",
89
+ # Dialect
90
+ "SQLDialect",
91
+ "PostgresDialect",
92
+ "MySQLDialect",
93
+ "SQLiteDialect",
94
+ "new_dialect",
95
+ # Builder
96
+ "QueryBuilder",
97
+ "MutationBuilder",
98
+ # Conditions
99
+ "eq",
100
+ "neq",
101
+ "gt",
102
+ "gte",
103
+ "lt",
104
+ "lte",
105
+ "is_in",
106
+ "not_in",
107
+ "like",
108
+ "contains",
109
+ "starts_with",
110
+ "ends_with",
111
+ "field",
112
+ "and_",
113
+ "or_",
114
+ "not_",
115
+ # Engine / Driver
116
+ "DatabaseDriver",
117
+ "JsonQLEngine",
118
+ # MongoDB
119
+ "MongoTranspiler",
120
+ "MongoResult",
121
+ "MongoDBDriver",
122
+ # Factory / Helpers
123
+ "env_or",
124
+ "load_schema",
125
+ "must_load_schema",
126
+ "create_driver",
127
+ "create_driver_with_dsn",
128
+ "connect_mongo",
129
+ "must_connect_mongo",
130
+ # Errors
131
+ "JsonQLError",
132
+ "JsonQLValidationError",
133
+ "JsonQLTranspileError",
134
+ "JsonQLExecutionError",
135
+ "AdapterError",
136
+ # Logger
137
+ "Logger",
138
+ "ConsoleLogger",
139
+ "NoOpLogger",
140
+ ]
141
+
142
+ __version__ = "0.1.0"
@@ -0,0 +1,42 @@
1
+ """JSONQL framework adapters — Flask, FastAPI, Django REST + MongoDB variants."""
2
+
3
+ from ..errors import AdapterError
4
+ from .base import (
5
+ AdapterOptions,
6
+ BaseHandler,
7
+ )
8
+ from .base import (
9
+ _infer_mutation as infer_mutation,
10
+ )
11
+ from .django_adapter import JsonQLDjangoView
12
+ from .django_mongo import JsonQLDjangoMongoView
13
+ from .fastapi_adapter import create_fastapi_router
14
+ from .fastapi_mongo import create_fastapi_mongo_router
15
+ from .flask_adapter import create_flask_blueprint
16
+ from .flask_mongo import create_flask_mongo_blueprint
17
+ from .mongo_base import (
18
+ MongoAdapterOptions,
19
+ MongoBaseHandler,
20
+ build_rest_mutation,
21
+ get_id_from_query,
22
+ )
23
+
24
+ __all__ = [
25
+ # SQL adapters
26
+ "BaseHandler",
27
+ "AdapterOptions",
28
+ "create_flask_blueprint",
29
+ "create_fastapi_router",
30
+ "JsonQLDjangoView",
31
+ # MongoDB adapters
32
+ "MongoBaseHandler",
33
+ "MongoAdapterOptions",
34
+ "create_flask_mongo_blueprint",
35
+ "create_fastapi_mongo_router",
36
+ "JsonQLDjangoMongoView",
37
+ # Shared helpers
38
+ "AdapterError",
39
+ "infer_mutation",
40
+ "get_id_from_query",
41
+ "build_rest_mutation",
42
+ ]
@@ -0,0 +1,300 @@
1
+ """Framework-agnostic base handler for the JSONQL request pipeline.
2
+
3
+ Subclasses only need to provide framework-specific input extraction
4
+ and error creation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ from ..driver import DatabaseDriver
13
+ from ..hydrator import ResultHydrator
14
+ from ..logger import ConsoleLogger, Logger, NoOpLogger
15
+ from ..parser import Parser
16
+ from ..transpiler import SQLTranspiler
17
+ from ..types import JsonQLMutation, JsonQLQuery, JsonQLSchema, is_mutation
18
+ from ..validator import Validator
19
+
20
+ # Type aliases for lifecycle hooks
21
+ Hook = Callable[..., Any]
22
+
23
+
24
+ @dataclass
25
+ class AdapterOptions:
26
+ """Configuration for a JSONQL adapter."""
27
+
28
+ schema: JsonQLSchema | None = None
29
+ schema_resolver: Callable[..., JsonQLSchema | None] | None = None
30
+ driver: DatabaseDriver | None = None
31
+ execute: Callable[[str, list[Any]], Awaitable[list[dict[str, Any]]]] | None = None
32
+ dialect: str = "sqlite"
33
+
34
+ debug: bool = False
35
+ logger: Logger | None = None
36
+
37
+ tables: list[str] | dict[str, str] | None = None
38
+
39
+ # Mutation status resolver — determines HTTP status for mutations.
40
+ # Signature: (op: str, context: Any) -> int
41
+ # Default behaviour (when None): 201 for "create", 200 for update/delete.
42
+ mutation_status: Callable[[str, Any], int] | None = None
43
+
44
+ # Lifecycle hooks
45
+ before_parse: Hook | None = None
46
+ after_parse: Hook | None = None
47
+ before_query: Hook | None = None
48
+ before_validate: Hook | None = None
49
+ after_validate: Hook | None = None
50
+ before_hydrate: Hook | None = None
51
+ after_hydrate: Hook | None = None
52
+ after_query: Hook | None = None
53
+
54
+ # Mutation hooks
55
+ before_create: Hook | None = None
56
+ after_create: Hook | None = None
57
+ before_update: Hook | None = None
58
+ after_update: Hook | None = None
59
+ before_delete: Hook | None = None
60
+ after_delete: Hook | None = None
61
+
62
+
63
+ # Re-export public helpers from mongo_base (shared between SQL and MongoDB)
64
+
65
+
66
+ def _infer_mutation(http_method: str, raw: dict[str, Any]) -> dict[str, Any]:
67
+ """Inject ``op`` into the raw query based on the HTTP method."""
68
+ if "op" in raw:
69
+ return raw
70
+
71
+ if "create" in raw:
72
+ raw["op"] = "create"
73
+ if "data" not in raw:
74
+ raw["data"] = raw.get("create")
75
+ return raw
76
+ if "update" in raw:
77
+ raw["op"] = "update"
78
+ if "patch" not in raw:
79
+ raw["patch"] = raw.get("update")
80
+ return raw
81
+ if "delete" in raw:
82
+ raw["op"] = "delete"
83
+ return raw
84
+
85
+ upsert = raw.get("upsert")
86
+ if isinstance(upsert, dict):
87
+ if "where" in upsert and "update" in upsert:
88
+ raw["op"] = "update"
89
+ raw["where"] = upsert["where"]
90
+ raw["patch"] = upsert["update"]
91
+ return raw
92
+ if "create" in upsert:
93
+ raw["op"] = "create"
94
+ raw["data"] = upsert["create"]
95
+ return raw
96
+
97
+ method = http_method.upper()
98
+ if method == "POST" and "data" in raw:
99
+ raw["op"] = "create"
100
+ elif method in ("PUT", "PATCH") and ("patch" in raw or "where" in raw):
101
+ raw["op"] = "update"
102
+ elif method == "DELETE" and "where" in raw:
103
+ raw["op"] = "delete"
104
+ return raw
105
+
106
+
107
+ class BaseHandler:
108
+ """Core pipeline: parse → validate → transpile → execute → hydrate."""
109
+
110
+ def __init__(self, options: AdapterOptions) -> None:
111
+ self.options = options
112
+ self.parser = Parser()
113
+ self.can_execute = bool(options.execute or options.driver)
114
+
115
+ dialect = options.dialect
116
+ if options.driver:
117
+ dialect = options.driver.dialect
118
+
119
+ self.transpiler = SQLTranspiler(dialect) if self.can_execute else None
120
+ self.hydrator = ResultHydrator() if self.can_execute else None
121
+
122
+ if options.logger:
123
+ self.logger: Logger = options.logger
124
+ elif options.debug:
125
+ self.logger = ConsoleLogger()
126
+ else:
127
+ self.logger = NoOpLogger()
128
+
129
+ async def process_request(
130
+ self,
131
+ raw_input: Any,
132
+ context: Any,
133
+ http_method: str,
134
+ path_name: str,
135
+ ) -> Any:
136
+ """Run the full JSONQL pipeline and return the result dict."""
137
+ raw_query = raw_input
138
+
139
+ # 1. beforeParse
140
+ if self.options.before_parse:
141
+ raw_query = await _await_maybe(self.options.before_parse(raw_query, context))
142
+
143
+ # 2. Infer mutation
144
+ if isinstance(raw_query, dict):
145
+ raw_query = _infer_mutation(http_method, raw_query)
146
+
147
+ # 3. Parse
148
+ statement = self.parser.parse(raw_query)
149
+
150
+ if self.options.after_parse:
151
+ statement = await _await_maybe(self.options.after_parse(statement, context))
152
+
153
+ # 4. Resolve table name
154
+ table_name: str | None = None
155
+ if isinstance(statement, JsonQLQuery):
156
+ table_name = statement.from_table or None
157
+ elif isinstance(statement, JsonQLMutation):
158
+ table_name = None # mutations don't carry from_table
159
+
160
+ table_name = self._resolve_table(statement, table_name, path_name)
161
+
162
+ if isinstance(statement, JsonQLQuery) and table_name and not statement.from_table:
163
+ statement.from_table = table_name
164
+
165
+ # 5. beforeQuery
166
+ if self.options.before_query:
167
+ statement = await _await_maybe(self.options.before_query(statement, context))
168
+
169
+ # 6. beforeValidate
170
+ if self.options.before_validate:
171
+ statement = await _await_maybe(self.options.before_validate(statement, context))
172
+
173
+ # 7. Resolve schema
174
+ schema: JsonQLSchema | None = None
175
+ if self.options.schema_resolver:
176
+ schema = await _await_maybe(self.options.schema_resolver(context))
177
+ else:
178
+ schema = self.options.schema
179
+
180
+ # 8. Validate
181
+ if schema and table_name and isinstance(statement, JsonQLQuery):
182
+ table_def = schema.tables.get(table_name)
183
+ if table_def and table_def.fields:
184
+ validator = Validator(schema, table_name)
185
+ validation = validator.validate(statement)
186
+
187
+ if self.options.after_validate:
188
+ await _await_maybe(self.options.after_validate(validation, context))
189
+
190
+ if not validation.valid:
191
+ return {
192
+ "error": "Validation Error",
193
+ "details": [
194
+ {"code": e.code, "message": e.message, "path": e.path}
195
+ for e in validation.errors
196
+ ],
197
+ }, 400
198
+
199
+ # 9. Execute
200
+ if self.can_execute and self.transpiler and table_name:
201
+ is_mut = is_mutation(statement)
202
+
203
+ # Mutation before-hooks
204
+ if is_mut and isinstance(statement, JsonQLMutation):
205
+ if statement.op == "create" and self.options.before_create:
206
+ statement = await _await_maybe(self.options.before_create(statement, context))
207
+ elif statement.op == "update" and self.options.before_update:
208
+ statement = await _await_maybe(self.options.before_update(statement, context))
209
+ elif statement.op == "delete" and self.options.before_delete:
210
+ statement = await _await_maybe(self.options.before_delete(statement, context))
211
+
212
+ # Transpile
213
+ result = self.transpiler.transpile(statement, table_name, schema)
214
+ self.logger.debug(f"[JSONQL] SQL: {result.sql}")
215
+ self.logger.debug(f"[JSONQL] Params: {result.args}")
216
+
217
+ # Execute
218
+ flat_rows: list[dict[str, Any]] = []
219
+ try:
220
+ if self.options.driver:
221
+ flat_rows = await self.options.driver.query(result.sql, result.args)
222
+ elif self.options.execute:
223
+ flat_rows = await self.options.execute(result.sql, result.args)
224
+ except Exception as exc:
225
+ self.logger.error(f"[JSONQL] Execution error: {exc}")
226
+ return {"error": "Execution Error", "details": str(exc)}, 400
227
+
228
+ # Mutation after-hooks
229
+ if is_mut and isinstance(statement, JsonQLMutation):
230
+ data: Any = {"meta": {"query": raw_input}, "data": flat_rows}
231
+ if statement.op == "create" and self.options.after_create:
232
+ data = await _await_maybe(self.options.after_create(data, context))
233
+ elif statement.op == "update" and self.options.after_update:
234
+ data = await _await_maybe(self.options.after_update(data, context))
235
+ elif statement.op == "delete" and self.options.after_delete:
236
+ data = await _await_maybe(self.options.after_delete(data, context))
237
+ if self.options.after_query:
238
+ data = await _await_maybe(self.options.after_query(data, context))
239
+
240
+ # Determine HTTP status: custom resolver → default 200
241
+ if self.options.mutation_status:
242
+ status = await _await_maybe(
243
+ self.options.mutation_status(statement.op or "", context)
244
+ )
245
+ else:
246
+ status = 200
247
+ return data, status
248
+
249
+ # Hydrate
250
+ if self.hydrator:
251
+ if self.options.before_hydrate:
252
+ flat_rows = await _await_maybe(self.options.before_hydrate(flat_rows, context))
253
+
254
+ hydrated = self.hydrator.hydrate(flat_rows, schema, table_name)
255
+
256
+ if self.options.after_hydrate:
257
+ hydrated = await _await_maybe(self.options.after_hydrate(hydrated, context))
258
+
259
+ result_data: Any = {"meta": {"query": raw_input}, "data": hydrated}
260
+ if self.options.after_query:
261
+ result_data = await _await_maybe(self.options.after_query(result_data, context))
262
+ return result_data, 200
263
+
264
+ return None, 200
265
+
266
+ def _resolve_table(
267
+ self,
268
+ statement: Any,
269
+ table_name: str | None,
270
+ path_name: str,
271
+ ) -> str | None:
272
+ tables = self.options.tables
273
+
274
+ if tables is None:
275
+ return table_name or path_name or None
276
+
277
+ if isinstance(tables, list):
278
+ if not table_name:
279
+ table_name = path_name
280
+ if table_name not in tables:
281
+ raise ValueError(f"Table '{table_name}' is not allowed")
282
+ return table_name
283
+
284
+ # dict mapping
285
+ mapped = tables.get(path_name)
286
+ if mapped:
287
+ return mapped
288
+ if not path_name and table_name:
289
+ if table_name not in tables.values():
290
+ raise ValueError(f"Table '{table_name}' is not allowed")
291
+ return table_name
292
+
293
+
294
+ async def _await_maybe(value: Any) -> Any:
295
+ """Await a value if it is a coroutine, otherwise return it directly."""
296
+ import asyncio
297
+
298
+ if asyncio.iscoroutine(value) or asyncio.isfuture(value):
299
+ return await value
300
+ return value
@@ -0,0 +1,98 @@
1
+ """Django adapter — a class-based view for JSONQL.
2
+
3
+ Usage::
4
+
5
+ # urls.py
6
+ from jsonql.adapters import JsonQLDjangoView, AdapterOptions
7
+
8
+ options = AdapterOptions(
9
+ dialect="postgres",
10
+ execute=run_sql,
11
+ schema=my_schema,
12
+ )
13
+
14
+ urlpatterns = [
15
+ path("jsonql/", JsonQLDjangoView.as_view(options=options)),
16
+ path("jsonql/<path:path>/", JsonQLDjangoView.as_view(options=options)),
17
+ ]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ from typing import Any, ClassVar
25
+
26
+ from ..errors import AdapterError, JsonQLError
27
+ from .base import AdapterOptions, BaseHandler
28
+
29
+
30
+ def _extract_raw_input(request: Any) -> Any:
31
+ """Extract the JSONQL query from a Django request.
32
+
33
+ - GET with ``?q=<json>`` → parse the JSON string
34
+ - GET without ``q`` → use query params as dict
35
+ - POST/PUT/PATCH/DELETE → use the JSON body
36
+ """
37
+ if request.method == "GET":
38
+ q = request.GET.get("q")
39
+ if q:
40
+ try:
41
+ return json.loads(q)
42
+ except (ValueError, json.JSONDecodeError):
43
+ return q
44
+ if request.GET:
45
+ return dict(request.GET)
46
+ return {}
47
+ try:
48
+ return json.loads(request.body) if request.body else {}
49
+ except (json.JSONDecodeError, ValueError):
50
+ return {}
51
+
52
+
53
+ class JsonQLDjangoView:
54
+ """Django class-based view for JSONQL.
55
+
56
+ Designed to work with or without Django REST Framework.
57
+ """
58
+
59
+ options: ClassVar[AdapterOptions]
60
+
61
+ def __init__(self, **kwargs: Any) -> None:
62
+ self._handler: BaseHandler | None = None
63
+ for key, value in kwargs.items():
64
+ setattr(self, key, value)
65
+
66
+ @classmethod
67
+ def as_view(cls, *, options: AdapterOptions | None = None, **initkwargs: Any) -> Any:
68
+ """Return a Django view function."""
69
+ from django.http import JsonResponse
70
+
71
+ handler = BaseHandler(options or cls.options)
72
+
73
+ def _run(coro: Any) -> Any:
74
+ loop = asyncio.new_event_loop()
75
+ try:
76
+ return loop.run_until_complete(coro)
77
+ finally:
78
+ loop.close()
79
+
80
+ def view(request: Any, path: str = "", **kwargs: Any) -> Any:
81
+ try:
82
+ raw_input = _extract_raw_input(request)
83
+ result, status = _run(
84
+ handler.process_request(raw_input, request, request.method, path)
85
+ )
86
+ return JsonResponse(result, status=status, safe=False)
87
+ except AdapterError as exc:
88
+ return JsonResponse({"error": str(exc)}, status=exc.status)
89
+ except (ValueError, TypeError, JsonQLError) as exc:
90
+ return JsonResponse({"error": str(exc)}, status=400)
91
+ except Exception as exc:
92
+ if handler.logger:
93
+ handler.logger.error(f"[JSONQL] Unhandled error: {exc}")
94
+ return JsonResponse({"error": str(exc)}, status=500)
95
+
96
+ from django.views.decorators.csrf import csrf_exempt
97
+
98
+ return csrf_exempt(view)
@@ -0,0 +1,94 @@
1
+ """Django adapter for MongoDB — a class-based view for JSONQL.
2
+
3
+ Usage::
4
+
5
+ # urls.py
6
+ from jsonql.adapters import JsonQLDjangoMongoView, MongoAdapterOptions
7
+ from jsonql import must_connect_mongo, must_load_schema
8
+
9
+ client, db = must_connect_mongo("mongodb://localhost:27017", "mydb")
10
+ schema = must_load_schema("schema.json")
11
+
12
+ options = MongoAdapterOptions(database=db, schema=schema)
13
+
14
+ urlpatterns = [
15
+ path("jsonql/", JsonQLDjangoMongoView.as_view(options=options)),
16
+ path("jsonql/<path:path>/", JsonQLDjangoMongoView.as_view(options=options)),
17
+ ]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ from typing import Any, ClassVar
25
+
26
+ from ..errors import AdapterError, JsonQLError
27
+ from .mongo_base import MongoAdapterOptions, MongoBaseHandler
28
+
29
+
30
+ def _extract_raw_input(request: Any) -> Any:
31
+ """Extract the JSONQL query from a Django request.
32
+
33
+ - GET with ``?q=<json>`` → parse the JSON string
34
+ - GET without ``q`` → use query params as dict
35
+ - POST/PUT/PATCH/DELETE → use the JSON body
36
+ """
37
+ if request.method == "GET":
38
+ q = request.GET.get("q")
39
+ if q:
40
+ try:
41
+ return json.loads(q)
42
+ except (ValueError, json.JSONDecodeError):
43
+ return q
44
+ if request.GET:
45
+ return dict(request.GET)
46
+ return {}
47
+ try:
48
+ return json.loads(request.body) if request.body else {}
49
+ except (json.JSONDecodeError, ValueError):
50
+ return {}
51
+
52
+
53
+ class JsonQLDjangoMongoView:
54
+ """Django class-based view for JSONQL with MongoDB."""
55
+
56
+ options: ClassVar[MongoAdapterOptions]
57
+
58
+ def __init__(self, **kwargs: Any) -> None:
59
+ for key, value in kwargs.items():
60
+ setattr(self, key, value)
61
+
62
+ @classmethod
63
+ def as_view(cls, *, options: MongoAdapterOptions | None = None, **initkwargs: Any) -> Any:
64
+ """Return a Django view function."""
65
+ from django.http import JsonResponse
66
+
67
+ handler = MongoBaseHandler(options or cls.options)
68
+
69
+ def _run(coro: Any) -> Any:
70
+ loop = asyncio.new_event_loop()
71
+ try:
72
+ return loop.run_until_complete(coro)
73
+ finally:
74
+ loop.close()
75
+
76
+ def view(request: Any, path: str = "", **kwargs: Any) -> Any:
77
+ try:
78
+ raw_input = _extract_raw_input(request)
79
+ result, status = _run(
80
+ handler.process_request(raw_input, request, request.method, path)
81
+ )
82
+ return JsonResponse(result, status=status, safe=False)
83
+ except AdapterError as exc:
84
+ return JsonResponse({"error": str(exc)}, status=exc.status)
85
+ except (ValueError, TypeError, JsonQLError) as exc:
86
+ return JsonResponse({"error": str(exc)}, status=400)
87
+ except Exception as exc:
88
+ if handler.logger:
89
+ handler.logger.error(f"[JSONQL] Unhandled error: {exc}")
90
+ return JsonResponse({"error": str(exc)}, status=500)
91
+
92
+ from django.views.decorators.csrf import csrf_exempt
93
+
94
+ return csrf_exempt(view)