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.
Files changed (64) hide show
  1. future/__init__.py +3 -0
  2. future/application.py +656 -0
  3. future/authentication/Auth0Authentication.py +9 -0
  4. future/authentication/Authentication.py +11 -0
  5. future/authentication/AzureADAuthentication.py +9 -0
  6. future/authentication/BasicAuthentication.py +9 -0
  7. future/authentication/KerberosAuthentication.py +9 -0
  8. future/authentication/KeycloakAuthentication.py +9 -0
  9. future/authentication/OAuth2Authentication.py +9 -0
  10. future/authentication/OpenIdConnectAuthentication.py +9 -0
  11. future/authentication/SAMLAuthentication.py +9 -0
  12. future/cli/__init__.py +1 -0
  13. future/cli/main.py +608 -0
  14. future/cli/stubs.py +114 -0
  15. future/controllers/__init__.py +4 -0
  16. future/controllers/base.py +8 -0
  17. future/controllers/builtins.py +41 -0
  18. future/controllers/graphql.py +23 -0
  19. future/controllers/openapi.py +224 -0
  20. future/databases/Clickhouse.py +176 -0
  21. future/databases/Connections.py +20 -0
  22. future/databases/Database.py +66 -0
  23. future/databases/Elasticsearch.py +146 -0
  24. future/databases/MongoDB.py +177 -0
  25. future/databases/MySQL.py +215 -0
  26. future/databases/Postgres.py +214 -0
  27. future/databases/Redis.py +146 -0
  28. future/databases/SQLite.py +219 -0
  29. future/exceptions.py +27 -0
  30. future/graphql/__init__.py +1 -0
  31. future/graphql/schema.py +148 -0
  32. future/lifespan.py +70 -0
  33. future/logger.py +53 -0
  34. future/middleware/Middleware.py +201 -0
  35. future/middleware/SessionMiddleware.py +77 -0
  36. future/middleware/__init__.py +21 -0
  37. future/migrations/Blueprint.py +54 -0
  38. future/migrations/Column.py +33 -0
  39. future/migrations/Migration.py +11 -0
  40. future/migrations/MigrationGenerator.py +137 -0
  41. future/migrations/Migrator.py +72 -0
  42. future/migrations/Schema.py +21 -0
  43. future/models/__init__.py +1 -0
  44. future/models/model.py +224 -0
  45. future/openapi.py +233 -0
  46. future/plugins/ElasticsearchPlugin.py +496 -0
  47. future/plugins/__init__.py +9 -0
  48. future/request.py +143 -0
  49. future/response.py +201 -0
  50. future/routing.py +342 -0
  51. future/seeds/SeedGenerator.py +95 -0
  52. future/seeds/SeedRunner.py +42 -0
  53. future/seeds/Seeder.py +9 -0
  54. future/settings.py +94 -0
  55. future/tasks/__init__.py +14 -0
  56. future/tasks/scheduler.py +267 -0
  57. future/testing/__init__.py +1 -0
  58. future/testing/client.py +135 -0
  59. future/types.py +47 -0
  60. future_framework-1.1.0.dist-info/METADATA +68 -0
  61. future_framework-1.1.0.dist-info/RECORD +64 -0
  62. future_framework-1.1.0.dist-info/WHEEL +4 -0
  63. future_framework-1.1.0.dist-info/entry_points.txt +3 -0
  64. 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,4 @@
1
+ from future.controllers.base import Controller
2
+ from future.controllers.builtins import DebugController, WebSocketController, WelcomeController
3
+ from future.controllers.graphql import GraphQLController
4
+ from future.controllers.openapi import OpenAPIController
@@ -0,0 +1,8 @@
1
+ from future.request import Request
2
+ from future.response import Response
3
+
4
+
5
+ class Controller:
6
+ def __init__(self, request: Request, response: Response) -> None:
7
+ self.request = request
8
+ self.response = response
@@ -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()