future-framework 1.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.
- future/__init__.py +3 -0
- future/application.py +656 -0
- future/authentication/Auth0Authentication.py +9 -0
- future/authentication/Authentication.py +11 -0
- future/authentication/AzureADAuthentication.py +9 -0
- future/authentication/BasicAuthentication.py +9 -0
- future/authentication/KerberosAuthentication.py +9 -0
- future/authentication/KeycloakAuthentication.py +9 -0
- future/authentication/OAuth2Authentication.py +9 -0
- future/authentication/OpenIdConnectAuthentication.py +9 -0
- future/authentication/SAMLAuthentication.py +9 -0
- future/cli/__init__.py +1 -0
- future/cli/main.py +608 -0
- future/cli/stubs.py +114 -0
- future/controllers/__init__.py +4 -0
- future/controllers/base.py +8 -0
- future/controllers/builtins.py +41 -0
- future/controllers/graphql.py +23 -0
- future/controllers/openapi.py +224 -0
- future/databases/Clickhouse.py +176 -0
- future/databases/Connections.py +20 -0
- future/databases/Database.py +66 -0
- future/databases/Elasticsearch.py +146 -0
- future/databases/MongoDB.py +177 -0
- future/databases/MySQL.py +215 -0
- future/databases/Postgres.py +214 -0
- future/databases/Redis.py +146 -0
- future/databases/SQLite.py +219 -0
- future/exceptions.py +27 -0
- future/graphql/__init__.py +1 -0
- future/graphql/schema.py +148 -0
- future/lifespan.py +70 -0
- future/logger.py +53 -0
- future/middleware/Middleware.py +201 -0
- future/middleware/SessionMiddleware.py +77 -0
- future/middleware/__init__.py +21 -0
- future/migrations/Blueprint.py +54 -0
- future/migrations/Column.py +33 -0
- future/migrations/Migration.py +11 -0
- future/migrations/MigrationGenerator.py +137 -0
- future/migrations/Migrator.py +72 -0
- future/migrations/Schema.py +21 -0
- future/models/__init__.py +1 -0
- future/models/model.py +224 -0
- future/openapi.py +233 -0
- future/plugins/ElasticsearchPlugin.py +496 -0
- future/plugins/__init__.py +9 -0
- future/request.py +143 -0
- future/response.py +201 -0
- future/routing.py +342 -0
- future/seeds/SeedGenerator.py +95 -0
- future/seeds/SeedRunner.py +42 -0
- future/seeds/Seeder.py +9 -0
- future/settings.py +94 -0
- future/tasks/__init__.py +14 -0
- future/tasks/scheduler.py +267 -0
- future/testing/__init__.py +1 -0
- future/testing/client.py +135 -0
- future/types.py +47 -0
- future_framework-1.1.0.dist-info/METADATA +68 -0
- future_framework-1.1.0.dist-info/RECORD +64 -0
- future_framework-1.1.0.dist-info/WHEEL +4 -0
- future_framework-1.1.0.dist-info/entry_points.txt +3 -0
- future_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
future/cli/stubs.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# File stubs for make:model / make:controller / make:middleware / make:plugin / make:task / make:seed.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
STUB_KINDS = {
|
|
8
|
+
"model": {
|
|
9
|
+
"directory": "app/models",
|
|
10
|
+
"suffix": "",
|
|
11
|
+
"template": '''from future.models import Model
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class {class_name}(Model):
|
|
16
|
+
__connection__ = "default"
|
|
17
|
+
# __table__ = "..." # optional; otherwise tableized class name
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
created_at: datetime
|
|
22
|
+
updated_at: datetime
|
|
23
|
+
''',
|
|
24
|
+
},
|
|
25
|
+
"controller": {
|
|
26
|
+
"directory": "app/controllers",
|
|
27
|
+
"suffix": "Controller",
|
|
28
|
+
"template": '''from future.controllers import Controller
|
|
29
|
+
from future.response import Response
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class {class_name}(Controller):
|
|
33
|
+
async def index(self) -> Response:
|
|
34
|
+
return self.response.json({{"message": "{class_name}"}}, status=200)
|
|
35
|
+
|
|
36
|
+
async def show(self, id: str) -> Response:
|
|
37
|
+
return self.response.json({{"id": id}}, status=200)
|
|
38
|
+
''',
|
|
39
|
+
},
|
|
40
|
+
"middleware": {
|
|
41
|
+
"directory": "app/middleware",
|
|
42
|
+
"suffix": "Middleware",
|
|
43
|
+
"template": '''from future.middleware import Middleware
|
|
44
|
+
from future.response import Response
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class {class_name}(Middleware):
|
|
49
|
+
name = "{class_name}"
|
|
50
|
+
priority = 0
|
|
51
|
+
|
|
52
|
+
async def before(self) -> Optional[Response]:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def after(self) -> Optional[Response]:
|
|
56
|
+
return None
|
|
57
|
+
''',
|
|
58
|
+
},
|
|
59
|
+
"plugin": {
|
|
60
|
+
"directory": "app/plugins",
|
|
61
|
+
"suffix": "Plugin",
|
|
62
|
+
"template": '''from future.plugins import Plugin
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class {class_name}(Plugin):
|
|
66
|
+
pass
|
|
67
|
+
''',
|
|
68
|
+
},
|
|
69
|
+
"task": {
|
|
70
|
+
"directory": "app/tasks",
|
|
71
|
+
"suffix": "",
|
|
72
|
+
"template": '''"""
|
|
73
|
+
{class_name} task — pass to Lifespan cron_tasks via Task(..., func=run).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run():
|
|
78
|
+
pass
|
|
79
|
+
''',
|
|
80
|
+
},
|
|
81
|
+
"seed": {
|
|
82
|
+
"directory": "database/seeds",
|
|
83
|
+
"suffix": "Seeder",
|
|
84
|
+
"template": '''from future.seeds.Seeder import Seeder
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class {class_name}(Seeder):
|
|
88
|
+
def run(self):
|
|
89
|
+
pass
|
|
90
|
+
''',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class StubMaker:
|
|
96
|
+
def class_name(self, name, suffix):
|
|
97
|
+
if suffix and not name.endswith(suffix):
|
|
98
|
+
return f"{name}{suffix}"
|
|
99
|
+
return name
|
|
100
|
+
|
|
101
|
+
def make(self, kind, name):
|
|
102
|
+
if kind not in STUB_KINDS:
|
|
103
|
+
raise ValueError(f"Unknown stub kind: {kind}")
|
|
104
|
+
config = STUB_KINDS[kind]
|
|
105
|
+
class_name = self.class_name(name, config["suffix"])
|
|
106
|
+
directory = Path(config["directory"])
|
|
107
|
+
if not Path("app").is_dir() and not Path("database").is_dir():
|
|
108
|
+
raise FileNotFoundError("No app/ or database/ directory found. Run future init first.")
|
|
109
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
path = directory / f"{class_name}.py"
|
|
111
|
+
if path.exists():
|
|
112
|
+
raise FileExistsError(f"File already exists: {path}")
|
|
113
|
+
path.write_text(config["template"].format(class_name=class_name))
|
|
114
|
+
return str(path)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from future.controllers.base import Controller
|
|
4
|
+
from future.response import Response, WebSocketResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DebugController(Controller):
|
|
8
|
+
async def test(self) -> Response:
|
|
9
|
+
return self.response.text("lolok")
|
|
10
|
+
|
|
11
|
+
async def hello(self) -> Response:
|
|
12
|
+
return self.response.json({"message": "hi"})
|
|
13
|
+
|
|
14
|
+
async def test_data(self, data: Any) -> Response:
|
|
15
|
+
return self.response.text(f"data: {data}", status=200)
|
|
16
|
+
|
|
17
|
+
async def some_handler(self, **params: Any) -> Response:
|
|
18
|
+
return self.response.text(f"Handled with params: {params}", status=200)
|
|
19
|
+
|
|
20
|
+
async def ping(self) -> Response:
|
|
21
|
+
return self.response.text("Pong\n")
|
|
22
|
+
|
|
23
|
+
async def test2(self, data: Any) -> Response:
|
|
24
|
+
return self.response.text(str(data), status=200)
|
|
25
|
+
|
|
26
|
+
async def args(self, user_id: Any, arg2: Any) -> Response:
|
|
27
|
+
return self.response.text(f"{user_id=}, {arg2=}\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WelcomeController(Controller):
|
|
31
|
+
async def root(self) -> Response:
|
|
32
|
+
return self.response.text("✨ Welcome to Future! ✨")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebSocketController(Controller):
|
|
36
|
+
"""Controller for WebSocket endpoints."""
|
|
37
|
+
|
|
38
|
+
async def websocket_handler(self, **params: Any) -> WebSocketResponse:
|
|
39
|
+
"""Echo WebSocket: greeting, then duplex Echo: <text> until disconnect."""
|
|
40
|
+
message = params.get("message", "Hello from WebSocket!")
|
|
41
|
+
return WebSocketResponse(self.request.receive, message=message)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from future.controllers.base import Controller
|
|
2
|
+
from future.graphql.schema import schema as demo_schema
|
|
3
|
+
from future.response import Response
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GraphQLController(Controller):
|
|
7
|
+
async def query(self) -> Response:
|
|
8
|
+
body = await self.request.json()
|
|
9
|
+
if not isinstance(body, dict):
|
|
10
|
+
return self.response.json({"errors": ["JSON body required"]}, status=400)
|
|
11
|
+
query = body.get("query")
|
|
12
|
+
if not query or not isinstance(query, str):
|
|
13
|
+
return self.response.json({"errors": ["query is required"]}, status=400)
|
|
14
|
+
variables = body.get("variables")
|
|
15
|
+
operation_name = body.get("operationName")
|
|
16
|
+
app = self.request.scope.get("app")
|
|
17
|
+
schema = (app.config.get("GRAPHQL_SCHEMA") if app and app.config else None) or demo_schema
|
|
18
|
+
result = await schema.execute(query, variable_values=variables, operation_name=operation_name)
|
|
19
|
+
payload = {"data": result.data}
|
|
20
|
+
if result.errors:
|
|
21
|
+
payload["errors"] = [str(error) for error in result.errors]
|
|
22
|
+
return self.response.json(payload, status=400)
|
|
23
|
+
return self.response.json(payload)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from future.controllers.base import Controller
|
|
5
|
+
from future.openapi import get_openapi_config, get_spec, path_prefix
|
|
6
|
+
from future.response import Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAPIController(Controller):
|
|
10
|
+
"""Controller for OpenAPI documentation endpoints."""
|
|
11
|
+
|
|
12
|
+
async def openapi(self) -> Response:
|
|
13
|
+
"""Serve the OpenAPI schema as JSON."""
|
|
14
|
+
spec = dict(get_spec())
|
|
15
|
+
scheme = self.request.scheme
|
|
16
|
+
host = self.request.host
|
|
17
|
+
port = f":{host.rsplit(':', 1)[1]}" if ":" in host else ""
|
|
18
|
+
paths = {}
|
|
19
|
+
for path, entry in (spec.get("paths") or {}).items():
|
|
20
|
+
item = dict(entry)
|
|
21
|
+
hosts = item.pop("x-future-hosts", None) or []
|
|
22
|
+
sub = item.pop("x-future-subdomain", None) or ""
|
|
23
|
+
if hosts:
|
|
24
|
+
item["servers"] = [{"url": f"{scheme}://{h}{port}", "description": sub or h} for h in hosts]
|
|
25
|
+
paths[path] = item
|
|
26
|
+
spec["paths"] = paths
|
|
27
|
+
# Scalar Test Request needs document-level servers or the URL bar is blank. List RouteGroup
|
|
28
|
+
# hosts first (other hosts before the docs Host) so subdomain APIs are the default selection.
|
|
29
|
+
if "servers" not in spec:
|
|
30
|
+
servers = []
|
|
31
|
+
seen = set()
|
|
32
|
+
for item in paths.values():
|
|
33
|
+
for server in item.get("servers") or []:
|
|
34
|
+
url = server.get("url")
|
|
35
|
+
if not url or url in seen:
|
|
36
|
+
continue
|
|
37
|
+
seen.add(url)
|
|
38
|
+
servers.append(dict(server))
|
|
39
|
+
current = f"{scheme}://{host}"
|
|
40
|
+
if current not in seen:
|
|
41
|
+
servers.append({"url": current, "description": "Application"})
|
|
42
|
+
if not servers:
|
|
43
|
+
servers = [{"url": current, "description": "Application"}]
|
|
44
|
+
docs_host = host.rsplit(":", 1)[0] if ":" in host else host
|
|
45
|
+
other = [s for s in servers if s["url"].split("://", 1)[-1].split("/")[0].rsplit(":", 1)[0] != docs_host]
|
|
46
|
+
same = [s for s in servers if s["url"].split("://", 1)[-1].split("/")[0].rsplit(":", 1)[0] == docs_host]
|
|
47
|
+
spec["servers"] = other + same
|
|
48
|
+
return self.response.json(spec, status=200)
|
|
49
|
+
|
|
50
|
+
async def redoc(self) -> Response:
|
|
51
|
+
"""Serve ReDoc HTML (OSS) or Redocly Reference Docs when license_key is set."""
|
|
52
|
+
spec = f"{path_prefix()}/openapi.json"
|
|
53
|
+
license_key = (get_openapi_config().get("redocly_license_key") or "").strip()
|
|
54
|
+
if license_key:
|
|
55
|
+
# Paid Redocly Reference Docs (includes Try it). Requires a valid license.
|
|
56
|
+
# https://cdn.redoc.ly/reference-docs/latest/redocly-reference-docs.min.js
|
|
57
|
+
key_js = json.dumps(license_key)
|
|
58
|
+
html = dedent(
|
|
59
|
+
f"""
|
|
60
|
+
<!DOCTYPE html>
|
|
61
|
+
<html>
|
|
62
|
+
<head>
|
|
63
|
+
<title>Redocly Reference</title>
|
|
64
|
+
<meta charset="utf-8"/>
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
66
|
+
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
|
67
|
+
<style>body {{ margin: 0; padding: 0; }}</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<div id="redocly_container"></div>
|
|
71
|
+
<script src="https://cdn.redoc.ly/reference-docs/latest/redocly-reference-docs.min.js"></script>
|
|
72
|
+
<script>
|
|
73
|
+
RedoclyReferenceDocs.init(
|
|
74
|
+
{json.dumps(spec)},
|
|
75
|
+
{{ licenseKey: {key_js} }},
|
|
76
|
+
document.querySelector("#redocly_container")
|
|
77
|
+
);
|
|
78
|
+
</script>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
81
|
+
"""
|
|
82
|
+
)
|
|
83
|
+
return self.response.html(html)
|
|
84
|
+
html = dedent(
|
|
85
|
+
f"""
|
|
86
|
+
<!DOCTYPE html>
|
|
87
|
+
<html>
|
|
88
|
+
<head>
|
|
89
|
+
<title>Redoc</title>
|
|
90
|
+
<meta charset="utf-8"/>
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
92
|
+
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
|
93
|
+
</head>
|
|
94
|
+
<body>
|
|
95
|
+
<redoc spec-url="{spec}"></redoc>
|
|
96
|
+
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
return self.response.html(html)
|
|
102
|
+
|
|
103
|
+
async def swagger_config(self) -> Response:
|
|
104
|
+
"""Serve Swagger configuration."""
|
|
105
|
+
swagger_config = {
|
|
106
|
+
"apisSorter": "alpha",
|
|
107
|
+
"operationsSorter": "alpha",
|
|
108
|
+
"docExpansion": "full",
|
|
109
|
+
}
|
|
110
|
+
return self.response.json(swagger_config, status=200)
|
|
111
|
+
|
|
112
|
+
async def swagger(self) -> Response:
|
|
113
|
+
"""Serve Swagger UI HTML."""
|
|
114
|
+
base = path_prefix()
|
|
115
|
+
html = dedent(
|
|
116
|
+
f"""
|
|
117
|
+
<!DOCTYPE html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="UTF-8">
|
|
121
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
122
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui.css">
|
|
123
|
+
<title>OpenAPI Swagger</title>
|
|
124
|
+
<style>
|
|
125
|
+
body {{
|
|
126
|
+
margin: 0;
|
|
127
|
+
padding: 0;
|
|
128
|
+
}}
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<div id="openapi"></div>
|
|
133
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
|
|
134
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js"></script>
|
|
135
|
+
<script>
|
|
136
|
+
window.addEventListener("load", function () {{
|
|
137
|
+
if (typeof SwaggerUIBundle === "undefined") {{
|
|
138
|
+
document.getElementById("openapi").textContent = "Failed to load Swagger UI (CDN blocked or offline).";
|
|
139
|
+
return;
|
|
140
|
+
}}
|
|
141
|
+
SwaggerUIBundle({{
|
|
142
|
+
url: "{base}/openapi.json",
|
|
143
|
+
dom_id: "#openapi",
|
|
144
|
+
configUrl: "{base}/swagger-config",
|
|
145
|
+
deepLinking: true,
|
|
146
|
+
presets: [
|
|
147
|
+
SwaggerUIBundle.presets.apis,
|
|
148
|
+
SwaggerUIStandalonePreset
|
|
149
|
+
],
|
|
150
|
+
plugins: [
|
|
151
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
|
152
|
+
],
|
|
153
|
+
layout: "StandaloneLayout"
|
|
154
|
+
}});
|
|
155
|
+
}});
|
|
156
|
+
</script>
|
|
157
|
+
</body>
|
|
158
|
+
</html>
|
|
159
|
+
"""
|
|
160
|
+
)
|
|
161
|
+
return self.response.html(html)
|
|
162
|
+
|
|
163
|
+
async def scalar(self) -> Response:
|
|
164
|
+
"""Serve Scalar API reference HTML."""
|
|
165
|
+
spec = f"{path_prefix()}/openapi.json"
|
|
166
|
+
scheme = self.request.scheme
|
|
167
|
+
host = self.request.host
|
|
168
|
+
port = f":{host.rsplit(':', 1)[1]}" if ":" in host else ""
|
|
169
|
+
path_servers = {}
|
|
170
|
+
for path, entry in (get_spec().get("paths") or {}).items():
|
|
171
|
+
hosts = entry.get("x-future-hosts") or []
|
|
172
|
+
if hosts:
|
|
173
|
+
path_servers[path] = f"{scheme}://{hosts[0]}{port}"
|
|
174
|
+
html = dedent(
|
|
175
|
+
f"""
|
|
176
|
+
<!DOCTYPE html>
|
|
177
|
+
<html>
|
|
178
|
+
<head>
|
|
179
|
+
<title>Scalar API Reference</title>
|
|
180
|
+
<meta charset="utf-8"/>
|
|
181
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
182
|
+
</head>
|
|
183
|
+
<body>
|
|
184
|
+
<div id="app"></div>
|
|
185
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
186
|
+
<script>
|
|
187
|
+
const pathServers = {json.dumps(path_servers)};
|
|
188
|
+
Scalar.createApiReference("#app", {{
|
|
189
|
+
url: {json.dumps(spec)},
|
|
190
|
+
// Scalar's modal uses the global server; force RouteGroup host for send + URL bar.
|
|
191
|
+
onBeforeRequest: ({{ requestBuilder }}) => {{
|
|
192
|
+
const base = pathServers[requestBuilder.path.raw];
|
|
193
|
+
if (base) {{
|
|
194
|
+
requestBuilder.baseUrl = base;
|
|
195
|
+
}}
|
|
196
|
+
}},
|
|
197
|
+
}});
|
|
198
|
+
</script>
|
|
199
|
+
</body>
|
|
200
|
+
</html>
|
|
201
|
+
"""
|
|
202
|
+
)
|
|
203
|
+
return self.response.html(html)
|
|
204
|
+
|
|
205
|
+
async def rapidoc(self) -> Response:
|
|
206
|
+
"""Serve RapiDoc HTML."""
|
|
207
|
+
spec = f"{path_prefix()}/openapi.json"
|
|
208
|
+
html = dedent(
|
|
209
|
+
f"""
|
|
210
|
+
<!DOCTYPE html>
|
|
211
|
+
<html>
|
|
212
|
+
<head>
|
|
213
|
+
<title>RapiDoc</title>
|
|
214
|
+
<meta charset="utf-8"/>
|
|
215
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
216
|
+
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
|
|
217
|
+
</head>
|
|
218
|
+
<body>
|
|
219
|
+
<rapi-doc spec-url="{spec}" render-style="read" show-header="true" allow-try="true"></rapi-doc>
|
|
220
|
+
</body>
|
|
221
|
+
</html>
|
|
222
|
+
"""
|
|
223
|
+
)
|
|
224
|
+
return self.response.html(html)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from future.databases.Database import Database
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Clickhouse(Database):
|
|
5
|
+
def __init__(self, host: str, port: int, username: str, password: str, database: str):
|
|
6
|
+
super().__init__(host, port, username, password, database)
|
|
7
|
+
self.client = None
|
|
8
|
+
|
|
9
|
+
def connect(self):
|
|
10
|
+
from clickhouse_driver import Client
|
|
11
|
+
self.client = Client(host=self.host, port=self.port, user=self.username, password=self.password, database=self.database)
|
|
12
|
+
return self.client
|
|
13
|
+
|
|
14
|
+
def disconnect(self):
|
|
15
|
+
self.client = None
|
|
16
|
+
|
|
17
|
+
def save(self, model):
|
|
18
|
+
if self.client is None:
|
|
19
|
+
self.connect()
|
|
20
|
+
table = model.tableize()
|
|
21
|
+
data = model.to_dict()
|
|
22
|
+
doc_id = data.get("id")
|
|
23
|
+
if doc_id is not None:
|
|
24
|
+
self.client.execute(f"ALTER TABLE `{table}` DELETE WHERE id = %(id)s", {"id": doc_id})
|
|
25
|
+
columns = list(data.keys())
|
|
26
|
+
col_sql = ", ".join(f"`{column}`" for column in columns)
|
|
27
|
+
self.client.execute(f"INSERT INTO `{table}` ({col_sql}) VALUES", [tuple(data[column] for column in columns)])
|
|
28
|
+
return {"status": "saved", "table": table, "id": doc_id}
|
|
29
|
+
|
|
30
|
+
def find(self, model, id):
|
|
31
|
+
if self.client is None:
|
|
32
|
+
self.connect()
|
|
33
|
+
table = model.tableize()
|
|
34
|
+
rows, columns = self.client.execute(f"SELECT * FROM `{table}` WHERE id = %(id)s LIMIT 1", {"id": id}, with_column_types=True)
|
|
35
|
+
if not rows:
|
|
36
|
+
return None
|
|
37
|
+
names = [column[0] for column in columns]
|
|
38
|
+
return model.__class__(**dict(zip(names, rows[0], strict=True)))
|
|
39
|
+
|
|
40
|
+
def all(self, model):
|
|
41
|
+
if self.client is None:
|
|
42
|
+
self.connect()
|
|
43
|
+
table = model.tableize()
|
|
44
|
+
rows, columns = self.client.execute(f"SELECT * FROM `{table}`", with_column_types=True)
|
|
45
|
+
names = [column[0] for column in columns]
|
|
46
|
+
return [model.__class__(**dict(zip(names, row, strict=True))) for row in rows]
|
|
47
|
+
|
|
48
|
+
def get(self, model, wheres, limit=None, orders=None):
|
|
49
|
+
if self.client is None:
|
|
50
|
+
self.connect()
|
|
51
|
+
table = model.tableize()
|
|
52
|
+
clauses = []
|
|
53
|
+
params = {}
|
|
54
|
+
for index, (column, operator, value) in enumerate(wheres):
|
|
55
|
+
if operator == "in":
|
|
56
|
+
values = list(value)
|
|
57
|
+
if not values:
|
|
58
|
+
return []
|
|
59
|
+
keys = []
|
|
60
|
+
for item_index, item in enumerate(values):
|
|
61
|
+
key = f"v{index}_{item_index}"
|
|
62
|
+
keys.append(f"%({key})s")
|
|
63
|
+
params[key] = item
|
|
64
|
+
clauses.append(f"`{column}` IN ({', '.join(keys)})")
|
|
65
|
+
continue
|
|
66
|
+
if operator == "like":
|
|
67
|
+
key = f"v{index}"
|
|
68
|
+
clauses.append(f"`{column}` LIKE %({key})s")
|
|
69
|
+
params[key] = value
|
|
70
|
+
continue
|
|
71
|
+
if operator not in ("=", ">", ">=", "<", "<=", "!="):
|
|
72
|
+
raise ValueError(f"Unsupported operator: {operator}")
|
|
73
|
+
key = f"v{index}"
|
|
74
|
+
clauses.append(f"`{column}` {operator} %({key})s")
|
|
75
|
+
params[key] = value
|
|
76
|
+
sql = f"SELECT * FROM `{table}`"
|
|
77
|
+
if clauses:
|
|
78
|
+
sql += f" WHERE {' AND '.join(clauses)}"
|
|
79
|
+
if orders:
|
|
80
|
+
sql += " ORDER BY " + ", ".join(f"`{column}` {direction}" for column, direction in orders)
|
|
81
|
+
if limit is not None:
|
|
82
|
+
sql += f" LIMIT {int(limit)}"
|
|
83
|
+
rows, columns = self.client.execute(sql, params, with_column_types=True)
|
|
84
|
+
names = [column[0] for column in columns]
|
|
85
|
+
return [model.__class__(**dict(zip(names, row, strict=True))) for row in rows]
|
|
86
|
+
|
|
87
|
+
def delete(self, model):
|
|
88
|
+
if self.client is None:
|
|
89
|
+
self.connect()
|
|
90
|
+
table = model.tableize()
|
|
91
|
+
doc_id = getattr(model, "id", None)
|
|
92
|
+
self.client.execute(f"ALTER TABLE `{table}` DELETE WHERE id = %(id)s", {"id": doc_id})
|
|
93
|
+
return {"status": "deleted", "table": table, "id": doc_id}
|
|
94
|
+
|
|
95
|
+
def update(self, model, changes):
|
|
96
|
+
if self.client is None:
|
|
97
|
+
self.connect()
|
|
98
|
+
table = model.tableize()
|
|
99
|
+
doc_id = getattr(model, "id", None)
|
|
100
|
+
if not changes:
|
|
101
|
+
return {"status": "noop", "table": table, "id": doc_id}
|
|
102
|
+
set_sql = ", ".join(f"`{column}` = %({column})s" for column in changes.keys())
|
|
103
|
+
params = dict(changes)
|
|
104
|
+
params["id"] = doc_id
|
|
105
|
+
self.client.execute(f"ALTER TABLE `{table}` UPDATE {set_sql} WHERE id = %(id)s", params)
|
|
106
|
+
return {"status": "updated", "table": table, "id": doc_id}
|
|
107
|
+
|
|
108
|
+
def schema_create(self, blueprint):
|
|
109
|
+
if self.client is None:
|
|
110
|
+
self.connect()
|
|
111
|
+
definitions = []
|
|
112
|
+
order_by = "tuple()"
|
|
113
|
+
for column in blueprint.columns:
|
|
114
|
+
if column.type == "string":
|
|
115
|
+
ch_type = "String"
|
|
116
|
+
elif column.type == "text":
|
|
117
|
+
ch_type = "String"
|
|
118
|
+
elif column.type == "integer":
|
|
119
|
+
ch_type = "Int64"
|
|
120
|
+
elif column.type == "float":
|
|
121
|
+
ch_type = "Float64"
|
|
122
|
+
elif column.type == "boolean":
|
|
123
|
+
ch_type = "UInt8"
|
|
124
|
+
elif column.type == "datetime":
|
|
125
|
+
ch_type = "DateTime"
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unsupported column type: {column.type}")
|
|
128
|
+
if column.is_nullable:
|
|
129
|
+
ch_type = f"Nullable({ch_type})"
|
|
130
|
+
definitions.append(f"`{column.name}` {ch_type}")
|
|
131
|
+
if column.is_primary:
|
|
132
|
+
order_by = f"`{column.name}`"
|
|
133
|
+
create_sql = f"CREATE TABLE IF NOT EXISTS `{blueprint.name}` ({', '.join(definitions)}) ENGINE = MergeTree() ORDER BY {order_by}"
|
|
134
|
+
self.client.execute(create_sql)
|
|
135
|
+
return {"status": "created", "table": blueprint.name}
|
|
136
|
+
|
|
137
|
+
def schema_drop(self, name):
|
|
138
|
+
if self.client is None:
|
|
139
|
+
self.connect()
|
|
140
|
+
self.client.execute(f"DROP TABLE IF EXISTS `{name}`")
|
|
141
|
+
return {"status": "dropped", "table": name}
|
|
142
|
+
|
|
143
|
+
def migrations_get(self):
|
|
144
|
+
if self.client is None:
|
|
145
|
+
self.connect()
|
|
146
|
+
self.client.execute("CREATE TABLE IF NOT EXISTS `migrations` (`name` String, `batch` Int32) ENGINE = MergeTree() ORDER BY (`batch`, `name`)")
|
|
147
|
+
rows = self.client.execute("SELECT name FROM migrations ORDER BY batch, name")
|
|
148
|
+
return [row[0] for row in rows]
|
|
149
|
+
|
|
150
|
+
def migrations_put(self, name, batch):
|
|
151
|
+
if self.client is None:
|
|
152
|
+
self.connect()
|
|
153
|
+
self.client.execute("CREATE TABLE IF NOT EXISTS `migrations` (`name` String, `batch` Int32) ENGINE = MergeTree() ORDER BY (`batch`, `name`)")
|
|
154
|
+
self.client.execute("INSERT INTO migrations (name, batch) VALUES", [(name, batch)])
|
|
155
|
+
|
|
156
|
+
def migrations_delete(self, name):
|
|
157
|
+
if self.client is None:
|
|
158
|
+
self.connect()
|
|
159
|
+
self.client.execute("ALTER TABLE migrations DELETE WHERE name = %(name)s", {"name": name})
|
|
160
|
+
|
|
161
|
+
def migrations_max_batch(self):
|
|
162
|
+
if self.client is None:
|
|
163
|
+
self.connect()
|
|
164
|
+
self.client.execute("CREATE TABLE IF NOT EXISTS `migrations` (`name` String, `batch` Int32) ENGINE = MergeTree() ORDER BY (`batch`, `name`)")
|
|
165
|
+
rows = self.client.execute("SELECT max(batch) FROM migrations")
|
|
166
|
+
if not rows or rows[0][0] is None:
|
|
167
|
+
return 0
|
|
168
|
+
return int(rows[0][0])
|
|
169
|
+
|
|
170
|
+
def migrations_batch_for(self, name):
|
|
171
|
+
if self.client is None:
|
|
172
|
+
self.connect()
|
|
173
|
+
rows = self.client.execute("SELECT batch FROM migrations WHERE name = %(name)s", {"name": name})
|
|
174
|
+
if not rows:
|
|
175
|
+
return None
|
|
176
|
+
return int(rows[0][0])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Named connection registry (Masonite-style logic).
|
|
2
|
+
# App config: DB = Connections().set_connection_details(DATABASES)
|
|
3
|
+
# Models: Connections().get_connection(name)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Connections:
|
|
7
|
+
_connections = {}
|
|
8
|
+
_default = None
|
|
9
|
+
|
|
10
|
+
def set_connection_details(self, databases):
|
|
11
|
+
self.__class__._default = databases["default"]
|
|
12
|
+
self.__class__._connections = {name: connection for name, connection in databases.items() if name != "default"}
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
def get_connection(self, name="default"):
|
|
16
|
+
if name == "default":
|
|
17
|
+
name = self.__class__._default
|
|
18
|
+
if not name or name not in self.__class__._connections:
|
|
19
|
+
raise RuntimeError("No database connections registered. Pass DATABASES in Future(config={...}) or call Connections().set_connection_details(DATABASES).")
|
|
20
|
+
return self.__class__._connections[name]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Interface for all database types (MySQL, Elasticsearch, Clickhouse, Redis, etc.).
|
|
2
|
+
# Concrete databases implement these with their own storage primitives
|
|
3
|
+
# (tables, indices, keys, ...). Model only calls these names.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Database:
|
|
7
|
+
def __init__(self, host: str, port: int, username: str, password: str, database: str):
|
|
8
|
+
self.host = host
|
|
9
|
+
self.port = port
|
|
10
|
+
self.username = username
|
|
11
|
+
self.password = password
|
|
12
|
+
self.database = database
|
|
13
|
+
|
|
14
|
+
def connect(self):
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
def disconnect(self):
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
def save(self, model):
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
def find(self, model, id):
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
def all(self, model):
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def get(self, model, wheres, limit=None, orders=None):
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def delete(self, model):
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def update(self, model, changes):
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
def schema_create(self, blueprint):
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def schema_drop(self, name):
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def migrations_get(self):
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def migrations_put(self, name, batch):
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def migrations_delete(self, name):
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def migrations_max_batch(self):
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def migrations_batch_for(self, name):
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def transaction(self):
|
|
60
|
+
client = getattr(self, "client", None)
|
|
61
|
+
if client is None:
|
|
62
|
+
self.connect()
|
|
63
|
+
client = getattr(self, "client", None)
|
|
64
|
+
if client is None or not hasattr(client, "begin"):
|
|
65
|
+
raise NotImplementedError(f"{type(self).__name__} does not support transactions")
|
|
66
|
+
return client.begin()
|