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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from future.databases.Database import Database
|
|
2
|
+
from sqlalchemy import create_engine, text
|
|
3
|
+
from urllib.parse import quote_plus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Postgres(Database):
|
|
7
|
+
def __init__(self, host: str, port: int, username: str, password: str, database: str):
|
|
8
|
+
super().__init__(host, port, username, password, database)
|
|
9
|
+
self.client = None
|
|
10
|
+
|
|
11
|
+
def connect(self):
|
|
12
|
+
url = f"postgresql+psycopg2://{quote_plus(self.username)}:{quote_plus(self.password)}@{self.host}:{self.port}/{self.database}"
|
|
13
|
+
self.client = create_engine(url)
|
|
14
|
+
return self.client
|
|
15
|
+
|
|
16
|
+
def disconnect(self):
|
|
17
|
+
if self.client is not None:
|
|
18
|
+
self.client.dispose()
|
|
19
|
+
self.client = None
|
|
20
|
+
|
|
21
|
+
def save(self, model):
|
|
22
|
+
if self.client is None:
|
|
23
|
+
self.connect()
|
|
24
|
+
table = model.tableize()
|
|
25
|
+
data = model.to_dict()
|
|
26
|
+
col_sql = ", ".join(f'"{column}"' for column in data.keys())
|
|
27
|
+
val_sql = ", ".join(f":{column}" for column in data.keys())
|
|
28
|
+
upd_sql = ", ".join(f'"{column}"=EXCLUDED."{column}"' for column in data.keys() if column != "id")
|
|
29
|
+
if "id" in data and upd_sql:
|
|
30
|
+
insert_sql = text(f'INSERT INTO "{table}" ({col_sql}) VALUES ({val_sql}) ON CONFLICT ("id") DO UPDATE SET {upd_sql}')
|
|
31
|
+
else:
|
|
32
|
+
insert_sql = text(f'INSERT INTO "{table}" ({col_sql}) VALUES ({val_sql})')
|
|
33
|
+
with self.client.begin() as connection:
|
|
34
|
+
connection.execute(insert_sql, data)
|
|
35
|
+
return {"status": "saved", "table": table, "id": data.get("id")}
|
|
36
|
+
|
|
37
|
+
def find(self, model, id):
|
|
38
|
+
if self.client is None:
|
|
39
|
+
self.connect()
|
|
40
|
+
table = model.tableize()
|
|
41
|
+
sql = text(f'SELECT * FROM "{table}" WHERE "id" = :id LIMIT 1')
|
|
42
|
+
with self.client.connect() as connection:
|
|
43
|
+
row = connection.execute(sql, {"id": id}).mappings().first()
|
|
44
|
+
if row is None:
|
|
45
|
+
return None
|
|
46
|
+
return model.__class__(**dict(row))
|
|
47
|
+
|
|
48
|
+
def all(self, model):
|
|
49
|
+
if self.client is None:
|
|
50
|
+
self.connect()
|
|
51
|
+
table = model.tableize()
|
|
52
|
+
sql = text(f'SELECT * FROM "{table}"')
|
|
53
|
+
with self.client.connect() as connection:
|
|
54
|
+
rows = connection.execute(sql).mappings().all()
|
|
55
|
+
return [model.__class__(**dict(row)) for row in rows]
|
|
56
|
+
|
|
57
|
+
def get(self, model, wheres, limit=None, orders=None):
|
|
58
|
+
if self.client is None:
|
|
59
|
+
self.connect()
|
|
60
|
+
table = model.tableize()
|
|
61
|
+
clauses = []
|
|
62
|
+
params = {}
|
|
63
|
+
for index, (column, operator, value) in enumerate(wheres):
|
|
64
|
+
if operator == "in":
|
|
65
|
+
values = list(value)
|
|
66
|
+
if not values:
|
|
67
|
+
return []
|
|
68
|
+
keys = []
|
|
69
|
+
for item_index, item in enumerate(values):
|
|
70
|
+
key = f"v{index}_{item_index}"
|
|
71
|
+
keys.append(f":{key}")
|
|
72
|
+
params[key] = item
|
|
73
|
+
clauses.append(f'"{column}" IN ({", ".join(keys)})')
|
|
74
|
+
continue
|
|
75
|
+
if operator == "like":
|
|
76
|
+
key = f"v{index}"
|
|
77
|
+
clauses.append(f'"{column}" LIKE :{key}')
|
|
78
|
+
params[key] = value
|
|
79
|
+
continue
|
|
80
|
+
if operator not in ("=", ">", ">=", "<", "<=", "!="):
|
|
81
|
+
raise ValueError(f"Unsupported operator: {operator}")
|
|
82
|
+
key = f"v{index}"
|
|
83
|
+
clauses.append(f'"{column}" {operator} :{key}')
|
|
84
|
+
params[key] = value
|
|
85
|
+
sql = f'SELECT * FROM "{table}"'
|
|
86
|
+
if clauses:
|
|
87
|
+
sql += f" WHERE {' AND '.join(clauses)}"
|
|
88
|
+
if orders:
|
|
89
|
+
sql += " ORDER BY " + ", ".join(f'"{column}" {direction}' for column, direction in orders)
|
|
90
|
+
if limit is not None:
|
|
91
|
+
sql += f" LIMIT {int(limit)}"
|
|
92
|
+
with self.client.connect() as connection:
|
|
93
|
+
rows = connection.execute(text(sql), params).mappings().all()
|
|
94
|
+
return [model.__class__(**dict(row)) for row in rows]
|
|
95
|
+
|
|
96
|
+
def delete(self, model):
|
|
97
|
+
if self.client is None:
|
|
98
|
+
self.connect()
|
|
99
|
+
table = model.tableize()
|
|
100
|
+
sql = text(f'DELETE FROM "{table}" WHERE "id" = :id')
|
|
101
|
+
with self.client.begin() as connection:
|
|
102
|
+
connection.execute(sql, {"id": getattr(model, "id", None)})
|
|
103
|
+
return {"status": "deleted", "table": table, "id": getattr(model, "id", None)}
|
|
104
|
+
|
|
105
|
+
def update(self, model, changes):
|
|
106
|
+
if self.client is None:
|
|
107
|
+
self.connect()
|
|
108
|
+
table = model.tableize()
|
|
109
|
+
if not changes:
|
|
110
|
+
return {"status": "noop", "table": table}
|
|
111
|
+
set_sql = ", ".join(f'"{column}"=:{column}' for column in changes.keys())
|
|
112
|
+
params = dict(changes)
|
|
113
|
+
params["id"] = getattr(model, "id", None)
|
|
114
|
+
sql = text(f'UPDATE "{table}" SET {set_sql} WHERE "id" = :id')
|
|
115
|
+
with self.client.begin() as connection:
|
|
116
|
+
connection.execute(sql, params)
|
|
117
|
+
return {"status": "updated", "table": table, "id": params["id"]}
|
|
118
|
+
|
|
119
|
+
def schema_create(self, blueprint):
|
|
120
|
+
if self.client is None:
|
|
121
|
+
self.connect()
|
|
122
|
+
definitions = []
|
|
123
|
+
uniques = []
|
|
124
|
+
indexes = []
|
|
125
|
+
for column in blueprint.columns:
|
|
126
|
+
if column.type == "string":
|
|
127
|
+
sql_type = f"VARCHAR({column.length or 255})"
|
|
128
|
+
elif column.type == "text":
|
|
129
|
+
sql_type = "TEXT"
|
|
130
|
+
elif column.type == "integer":
|
|
131
|
+
sql_type = "BIGINT"
|
|
132
|
+
elif column.type == "float":
|
|
133
|
+
sql_type = "DOUBLE PRECISION"
|
|
134
|
+
elif column.type == "boolean":
|
|
135
|
+
sql_type = "BOOLEAN"
|
|
136
|
+
elif column.type == "datetime":
|
|
137
|
+
sql_type = "TIMESTAMP"
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(f"Unsupported column type: {column.type}")
|
|
140
|
+
null_sql = "NULL" if column.is_nullable else "NOT NULL"
|
|
141
|
+
default_sql = ""
|
|
142
|
+
if column.default_value is not None:
|
|
143
|
+
if isinstance(column.default_value, str):
|
|
144
|
+
default_sql = f" DEFAULT '{column.default_value}'"
|
|
145
|
+
else:
|
|
146
|
+
default_sql = f" DEFAULT {column.default_value}"
|
|
147
|
+
primary_sql = " PRIMARY KEY" if column.is_primary else ""
|
|
148
|
+
definitions.append(f"\"{column.name}\" {sql_type} {null_sql}{default_sql}{primary_sql}")
|
|
149
|
+
if column.is_unique and not column.is_primary:
|
|
150
|
+
uniques.append(f"UNIQUE (\"{column.name}\")")
|
|
151
|
+
if column.is_index and not column.is_primary and not column.is_unique:
|
|
152
|
+
indexes.append(column.name)
|
|
153
|
+
parts = definitions + uniques
|
|
154
|
+
create_sql = text(f"CREATE TABLE IF NOT EXISTS \"{blueprint.name}\" ({', '.join(parts)})")
|
|
155
|
+
with self.client.begin() as connection:
|
|
156
|
+
connection.execute(create_sql)
|
|
157
|
+
for index_name in indexes:
|
|
158
|
+
connection.execute(text(f"CREATE INDEX IF NOT EXISTS \"index_{blueprint.name}_{index_name}\" ON \"{blueprint.name}\" (\"{index_name}\")"))
|
|
159
|
+
return {"status": "created", "table": blueprint.name}
|
|
160
|
+
|
|
161
|
+
def schema_drop(self, name):
|
|
162
|
+
if self.client is None:
|
|
163
|
+
self.connect()
|
|
164
|
+
with self.client.begin() as connection:
|
|
165
|
+
connection.execute(text(f"DROP TABLE IF EXISTS \"{name}\""))
|
|
166
|
+
return {"status": "dropped", "table": name}
|
|
167
|
+
|
|
168
|
+
def table_exists(self, name):
|
|
169
|
+
if self.client is None:
|
|
170
|
+
self.connect()
|
|
171
|
+
sql = text("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = :name LIMIT 1")
|
|
172
|
+
with self.client.connect() as connection:
|
|
173
|
+
return connection.execute(sql, {"name": name}).first() is not None
|
|
174
|
+
|
|
175
|
+
def _ensure_migrations_table(self):
|
|
176
|
+
if self.client is None:
|
|
177
|
+
self.connect()
|
|
178
|
+
sql = text("CREATE TABLE IF NOT EXISTS \"migrations\" (\"name\" VARCHAR(255) PRIMARY KEY, \"batch\" INT NOT NULL)")
|
|
179
|
+
with self.client.begin() as connection:
|
|
180
|
+
connection.execute(sql)
|
|
181
|
+
|
|
182
|
+
def migrations_get(self):
|
|
183
|
+
self._ensure_migrations_table()
|
|
184
|
+
with self.client.connect() as connection:
|
|
185
|
+
rows = connection.execute(text("SELECT \"name\" FROM \"migrations\" ORDER BY \"batch\", \"name\"")).mappings().all()
|
|
186
|
+
return [row["name"] for row in rows]
|
|
187
|
+
|
|
188
|
+
def migrations_put(self, name, batch):
|
|
189
|
+
self._ensure_migrations_table()
|
|
190
|
+
sql = text("INSERT INTO \"migrations\" (\"name\", \"batch\") VALUES (:name, :batch) ON CONFLICT (\"name\") DO UPDATE SET \"batch\" = EXCLUDED.\"batch\"")
|
|
191
|
+
with self.client.begin() as connection:
|
|
192
|
+
connection.execute(sql, {"name": name, "batch": batch})
|
|
193
|
+
|
|
194
|
+
def migrations_delete(self, name):
|
|
195
|
+
self._ensure_migrations_table()
|
|
196
|
+
sql = text("DELETE FROM \"migrations\" WHERE \"name\" = :name")
|
|
197
|
+
with self.client.begin() as connection:
|
|
198
|
+
connection.execute(sql, {"name": name})
|
|
199
|
+
|
|
200
|
+
def migrations_max_batch(self):
|
|
201
|
+
self._ensure_migrations_table()
|
|
202
|
+
with self.client.connect() as connection:
|
|
203
|
+
row = connection.execute(text("SELECT MAX(\"batch\") AS batch FROM \"migrations\"")).mappings().first()
|
|
204
|
+
if row is None or row["batch"] is None:
|
|
205
|
+
return 0
|
|
206
|
+
return int(row["batch"])
|
|
207
|
+
|
|
208
|
+
def migrations_batch_for(self, name):
|
|
209
|
+
self._ensure_migrations_table()
|
|
210
|
+
with self.client.connect() as connection:
|
|
211
|
+
row = connection.execute(text("SELECT \"batch\" FROM \"migrations\" WHERE \"name\" = :name"), {"name": name}).mappings().first()
|
|
212
|
+
if row is None:
|
|
213
|
+
return None
|
|
214
|
+
return int(row["batch"])
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from future.databases.Database import Database
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Redis(Database):
|
|
8
|
+
def __init__(self, host: str, port: int, username: str, password: str, database: str):
|
|
9
|
+
super().__init__(host, port, username, password, database)
|
|
10
|
+
self.client = None
|
|
11
|
+
|
|
12
|
+
def connect(self):
|
|
13
|
+
import redis
|
|
14
|
+
db_index = int(self.database) if str(self.database).isdigit() else 0
|
|
15
|
+
self.client = redis.Redis(host=self.host or "127.0.0.1", port=self.port or 6379, username=self.username or None, password=self.password or None, db=db_index, decode_responses=True)
|
|
16
|
+
self.client.ping()
|
|
17
|
+
return self.client
|
|
18
|
+
|
|
19
|
+
def disconnect(self):
|
|
20
|
+
if self.client is not None:
|
|
21
|
+
self.client.close()
|
|
22
|
+
self.client = None
|
|
23
|
+
|
|
24
|
+
def _key(self, table, id):
|
|
25
|
+
return f"{table}:{id}"
|
|
26
|
+
|
|
27
|
+
def _ids_key(self, table):
|
|
28
|
+
return f"{table}:ids"
|
|
29
|
+
|
|
30
|
+
def save(self, model):
|
|
31
|
+
if self.client is None:
|
|
32
|
+
self.connect()
|
|
33
|
+
table = model.tableize()
|
|
34
|
+
data = model.to_dict()
|
|
35
|
+
doc_id = data.get("id")
|
|
36
|
+
if doc_id is None:
|
|
37
|
+
raise ValueError("Redis models require an id for save()")
|
|
38
|
+
self.client.set(self._key(table, doc_id), json.dumps(data))
|
|
39
|
+
self.client.sadd(self._ids_key(table), str(doc_id))
|
|
40
|
+
return {"status": "saved", "key": self._key(table, doc_id), "id": doc_id}
|
|
41
|
+
|
|
42
|
+
def find(self, model, id):
|
|
43
|
+
if self.client is None:
|
|
44
|
+
self.connect()
|
|
45
|
+
raw = self.client.get(self._key(model.tableize(), id))
|
|
46
|
+
if raw is None:
|
|
47
|
+
return None
|
|
48
|
+
return model.__class__(**json.loads(raw))
|
|
49
|
+
|
|
50
|
+
def all(self, model):
|
|
51
|
+
if self.client is None:
|
|
52
|
+
self.connect()
|
|
53
|
+
table = model.tableize()
|
|
54
|
+
ids = self.client.smembers(self._ids_key(table))
|
|
55
|
+
rows = []
|
|
56
|
+
for doc_id in ids:
|
|
57
|
+
raw = self.client.get(self._key(table, doc_id))
|
|
58
|
+
if raw is not None:
|
|
59
|
+
rows.append(model.__class__(**json.loads(raw)))
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
def get(self, model, wheres, limit=None, orders=None):
|
|
63
|
+
rows = self.all(model)
|
|
64
|
+
for column, operator, value in wheres:
|
|
65
|
+
filtered = []
|
|
66
|
+
for row in rows:
|
|
67
|
+
current = getattr(row, column, None)
|
|
68
|
+
if operator == "=" and current == value:
|
|
69
|
+
filtered.append(row)
|
|
70
|
+
elif operator == "!=" and current != value:
|
|
71
|
+
filtered.append(row)
|
|
72
|
+
elif operator == ">" and current is not None and current > value:
|
|
73
|
+
filtered.append(row)
|
|
74
|
+
elif operator == ">=" and current is not None and current >= value:
|
|
75
|
+
filtered.append(row)
|
|
76
|
+
elif operator == "<" and current is not None and current < value:
|
|
77
|
+
filtered.append(row)
|
|
78
|
+
elif operator == "<=" and current is not None and current <= value:
|
|
79
|
+
filtered.append(row)
|
|
80
|
+
elif operator == "in" and current in list(value):
|
|
81
|
+
filtered.append(row)
|
|
82
|
+
elif operator == "like" and current is not None:
|
|
83
|
+
parts = []
|
|
84
|
+
for char in str(value):
|
|
85
|
+
if char == "%":
|
|
86
|
+
parts.append(".*")
|
|
87
|
+
elif char == "_":
|
|
88
|
+
parts.append(".")
|
|
89
|
+
else:
|
|
90
|
+
parts.append(re.escape(char))
|
|
91
|
+
if re.search("^" + "".join(parts) + "$", str(current)):
|
|
92
|
+
filtered.append(row)
|
|
93
|
+
elif operator not in ("=", "!=", ">", ">=", "<", "<=", "in", "like"):
|
|
94
|
+
raise ValueError(f"Unsupported operator: {operator}")
|
|
95
|
+
rows = filtered
|
|
96
|
+
if orders:
|
|
97
|
+
for column, direction in reversed(orders):
|
|
98
|
+
rows.sort(key=lambda row: getattr(row, column, None), reverse=str(direction).upper() == "DESC")
|
|
99
|
+
if limit is not None:
|
|
100
|
+
rows = rows[: int(limit)]
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
def delete(self, model):
|
|
104
|
+
if self.client is None:
|
|
105
|
+
self.connect()
|
|
106
|
+
table = model.tableize()
|
|
107
|
+
doc_id = getattr(model, "id", None)
|
|
108
|
+
self.client.delete(self._key(table, doc_id))
|
|
109
|
+
self.client.srem(self._ids_key(table), str(doc_id))
|
|
110
|
+
return {"status": "deleted", "key": self._key(table, doc_id), "id": doc_id}
|
|
111
|
+
|
|
112
|
+
def update(self, model, changes):
|
|
113
|
+
if self.client is None:
|
|
114
|
+
self.connect()
|
|
115
|
+
table = model.tableize()
|
|
116
|
+
doc_id = getattr(model, "id", None)
|
|
117
|
+
if not changes:
|
|
118
|
+
return {"status": "noop", "id": doc_id}
|
|
119
|
+
raw = self.client.get(self._key(table, doc_id))
|
|
120
|
+
if raw is None:
|
|
121
|
+
return {"status": "missing", "id": doc_id}
|
|
122
|
+
data = json.loads(raw)
|
|
123
|
+
data.update(changes)
|
|
124
|
+
self.client.set(self._key(table, doc_id), json.dumps(data))
|
|
125
|
+
return {"status": "updated", "id": doc_id}
|
|
126
|
+
|
|
127
|
+
def schema_create(self, blueprint):
|
|
128
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
129
|
+
|
|
130
|
+
def schema_drop(self, name):
|
|
131
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
132
|
+
|
|
133
|
+
def migrations_get(self):
|
|
134
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
135
|
+
|
|
136
|
+
def migrations_put(self, name, batch):
|
|
137
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
138
|
+
|
|
139
|
+
def migrations_delete(self, name):
|
|
140
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
141
|
+
|
|
142
|
+
def migrations_max_batch(self):
|
|
143
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
144
|
+
|
|
145
|
+
def migrations_batch_for(self, name):
|
|
146
|
+
raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from future.databases.Database import Database
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from sqlalchemy import create_engine, text
|
|
4
|
+
from sqlalchemy.pool import StaticPool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SQLite(Database):
|
|
8
|
+
def __init__(self, host: str = "", port: int = 0, username: str = "", password: str = "", database: str = "database"):
|
|
9
|
+
super().__init__(host, port, username, password, database)
|
|
10
|
+
self.client = None
|
|
11
|
+
|
|
12
|
+
def connect(self):
|
|
13
|
+
name = self.database or ":memory:"
|
|
14
|
+
if name == ":memory:":
|
|
15
|
+
self.client = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
|
16
|
+
return self.client
|
|
17
|
+
path = Path(f"{name}.sqlite")
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
self.client = create_engine(f"sqlite:///{path.as_posix()}", connect_args={"check_same_thread": False})
|
|
20
|
+
with self.client.connect() as connection:
|
|
21
|
+
connection.execute(text("SELECT 1"))
|
|
22
|
+
return self.client
|
|
23
|
+
|
|
24
|
+
def disconnect(self):
|
|
25
|
+
if self.client is not None:
|
|
26
|
+
self.client.dispose()
|
|
27
|
+
self.client = None
|
|
28
|
+
|
|
29
|
+
def save(self, model):
|
|
30
|
+
if self.client is None:
|
|
31
|
+
self.connect()
|
|
32
|
+
table = model.tableize()
|
|
33
|
+
data = model.to_dict()
|
|
34
|
+
col_sql = ", ".join(f'"{column}"' for column in data.keys())
|
|
35
|
+
val_sql = ", ".join(f":{column}" for column in data.keys())
|
|
36
|
+
upd_sql = ", ".join(f'"{column}"=excluded."{column}"' for column in data.keys() if column != "id")
|
|
37
|
+
if "id" in data and upd_sql:
|
|
38
|
+
insert_sql = text(f'INSERT INTO "{table}" ({col_sql}) VALUES ({val_sql}) ON CONFLICT("id") DO UPDATE SET {upd_sql}')
|
|
39
|
+
else:
|
|
40
|
+
insert_sql = text(f'INSERT INTO "{table}" ({col_sql}) VALUES ({val_sql})')
|
|
41
|
+
with self.client.begin() as connection:
|
|
42
|
+
connection.execute(insert_sql, data)
|
|
43
|
+
return {"status": "saved", "table": table, "id": data.get("id")}
|
|
44
|
+
|
|
45
|
+
def find(self, model, id):
|
|
46
|
+
if self.client is None:
|
|
47
|
+
self.connect()
|
|
48
|
+
table = model.tableize()
|
|
49
|
+
sql = text(f'SELECT * FROM "{table}" WHERE "id" = :id LIMIT 1')
|
|
50
|
+
with self.client.connect() as connection:
|
|
51
|
+
row = connection.execute(sql, {"id": id}).mappings().first()
|
|
52
|
+
if row is None:
|
|
53
|
+
return None
|
|
54
|
+
return model.__class__(**dict(row))
|
|
55
|
+
|
|
56
|
+
def all(self, model):
|
|
57
|
+
if self.client is None:
|
|
58
|
+
self.connect()
|
|
59
|
+
table = model.tableize()
|
|
60
|
+
sql = text(f'SELECT * FROM "{table}"')
|
|
61
|
+
with self.client.connect() as connection:
|
|
62
|
+
rows = connection.execute(sql).mappings().all()
|
|
63
|
+
return [model.__class__(**dict(row)) for row in rows]
|
|
64
|
+
|
|
65
|
+
def get(self, model, wheres, limit=None, orders=None):
|
|
66
|
+
if self.client is None:
|
|
67
|
+
self.connect()
|
|
68
|
+
table = model.tableize()
|
|
69
|
+
clauses = []
|
|
70
|
+
params = {}
|
|
71
|
+
for index, (column, operator, value) in enumerate(wheres):
|
|
72
|
+
if operator == "in":
|
|
73
|
+
values = list(value)
|
|
74
|
+
if not values:
|
|
75
|
+
return []
|
|
76
|
+
keys = []
|
|
77
|
+
for item_index, item in enumerate(values):
|
|
78
|
+
key = f"v{index}_{item_index}"
|
|
79
|
+
keys.append(f":{key}")
|
|
80
|
+
params[key] = item
|
|
81
|
+
clauses.append(f'"{column}" IN ({", ".join(keys)})')
|
|
82
|
+
continue
|
|
83
|
+
if operator == "like":
|
|
84
|
+
key = f"v{index}"
|
|
85
|
+
clauses.append(f'"{column}" LIKE :{key}')
|
|
86
|
+
params[key] = value
|
|
87
|
+
continue
|
|
88
|
+
if operator not in ("=", ">", ">=", "<", "<=", "!="):
|
|
89
|
+
raise ValueError(f"Unsupported operator: {operator}")
|
|
90
|
+
key = f"v{index}"
|
|
91
|
+
clauses.append(f'"{column}" {operator} :{key}')
|
|
92
|
+
params[key] = value
|
|
93
|
+
sql = f'SELECT * FROM "{table}"'
|
|
94
|
+
if clauses:
|
|
95
|
+
sql += f" WHERE {' AND '.join(clauses)}"
|
|
96
|
+
if orders:
|
|
97
|
+
sql += " ORDER BY " + ", ".join(f'"{column}" {direction}' for column, direction in orders)
|
|
98
|
+
if limit is not None:
|
|
99
|
+
sql += f" LIMIT {int(limit)}"
|
|
100
|
+
with self.client.connect() as connection:
|
|
101
|
+
rows = connection.execute(text(sql), params).mappings().all()
|
|
102
|
+
return [model.__class__(**dict(row)) for row in rows]
|
|
103
|
+
|
|
104
|
+
def delete(self, model):
|
|
105
|
+
if self.client is None:
|
|
106
|
+
self.connect()
|
|
107
|
+
table = model.tableize()
|
|
108
|
+
sql = text(f'DELETE FROM "{table}" WHERE "id" = :id')
|
|
109
|
+
with self.client.begin() as connection:
|
|
110
|
+
connection.execute(sql, {"id": getattr(model, "id", None)})
|
|
111
|
+
return {"status": "deleted", "table": table, "id": getattr(model, "id", None)}
|
|
112
|
+
|
|
113
|
+
def update(self, model, changes):
|
|
114
|
+
if self.client is None:
|
|
115
|
+
self.connect()
|
|
116
|
+
table = model.tableize()
|
|
117
|
+
if not changes:
|
|
118
|
+
return {"status": "noop", "table": table}
|
|
119
|
+
set_sql = ", ".join(f'"{column}"=:{column}' for column in changes.keys())
|
|
120
|
+
params = dict(changes)
|
|
121
|
+
params["id"] = getattr(model, "id", None)
|
|
122
|
+
sql = text(f'UPDATE "{table}" SET {set_sql} WHERE "id" = :id')
|
|
123
|
+
with self.client.begin() as connection:
|
|
124
|
+
connection.execute(sql, params)
|
|
125
|
+
return {"status": "updated", "table": table, "id": params["id"]}
|
|
126
|
+
|
|
127
|
+
def schema_create(self, blueprint):
|
|
128
|
+
if self.client is None:
|
|
129
|
+
self.connect()
|
|
130
|
+
definitions = []
|
|
131
|
+
indexes = []
|
|
132
|
+
for column in blueprint.columns:
|
|
133
|
+
if column.type == "string":
|
|
134
|
+
sql_type = f"VARCHAR({column.length or 255})"
|
|
135
|
+
elif column.type == "text":
|
|
136
|
+
sql_type = "TEXT"
|
|
137
|
+
elif column.type == "integer":
|
|
138
|
+
sql_type = "INTEGER"
|
|
139
|
+
elif column.type == "float":
|
|
140
|
+
sql_type = "REAL"
|
|
141
|
+
elif column.type == "boolean":
|
|
142
|
+
sql_type = "INTEGER"
|
|
143
|
+
elif column.type == "datetime":
|
|
144
|
+
sql_type = "DATETIME"
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError(f"Unsupported column type: {column.type}")
|
|
147
|
+
null_sql = "NULL" if column.is_nullable else "NOT NULL"
|
|
148
|
+
default_sql = ""
|
|
149
|
+
if column.default_value is not None:
|
|
150
|
+
if isinstance(column.default_value, str):
|
|
151
|
+
default_sql = f" DEFAULT '{column.default_value}'"
|
|
152
|
+
else:
|
|
153
|
+
default_sql = f" DEFAULT {column.default_value}"
|
|
154
|
+
primary_sql = " PRIMARY KEY" if column.is_primary else ""
|
|
155
|
+
unique_sql = " UNIQUE" if column.is_unique and not column.is_primary else ""
|
|
156
|
+
definitions.append(f'"{column.name}" {sql_type} {null_sql}{default_sql}{primary_sql}{unique_sql}')
|
|
157
|
+
if column.is_index and not column.is_primary and not column.is_unique:
|
|
158
|
+
indexes.append(f'CREATE INDEX IF NOT EXISTS "index_{blueprint.name}_{column.name}" ON "{blueprint.name}" ("{column.name}")')
|
|
159
|
+
create_sql = text(f'CREATE TABLE IF NOT EXISTS "{blueprint.name}" ({", ".join(definitions)})')
|
|
160
|
+
with self.client.begin() as connection:
|
|
161
|
+
connection.execute(create_sql)
|
|
162
|
+
for index_sql in indexes:
|
|
163
|
+
connection.execute(text(index_sql))
|
|
164
|
+
return {"status": "created", "table": blueprint.name}
|
|
165
|
+
|
|
166
|
+
def schema_drop(self, name):
|
|
167
|
+
if self.client is None:
|
|
168
|
+
self.connect()
|
|
169
|
+
with self.client.begin() as connection:
|
|
170
|
+
connection.execute(text(f'DROP TABLE IF EXISTS "{name}"'))
|
|
171
|
+
return {"status": "dropped", "table": name}
|
|
172
|
+
|
|
173
|
+
def table_exists(self, name):
|
|
174
|
+
if self.client is None:
|
|
175
|
+
self.connect()
|
|
176
|
+
sql = text('SELECT 1 FROM sqlite_master WHERE type = \'table\' AND name = :name LIMIT 1')
|
|
177
|
+
with self.client.connect() as connection:
|
|
178
|
+
return connection.execute(sql, {"name": name}).first() is not None
|
|
179
|
+
|
|
180
|
+
def _ensure_migrations_table(self):
|
|
181
|
+
if self.client is None:
|
|
182
|
+
self.connect()
|
|
183
|
+
sql = text('CREATE TABLE IF NOT EXISTS "migrations" ("name" VARCHAR(255) PRIMARY KEY, "batch" INTEGER NOT NULL)')
|
|
184
|
+
with self.client.begin() as connection:
|
|
185
|
+
connection.execute(sql)
|
|
186
|
+
|
|
187
|
+
def migrations_get(self):
|
|
188
|
+
self._ensure_migrations_table()
|
|
189
|
+
with self.client.connect() as connection:
|
|
190
|
+
rows = connection.execute(text('SELECT "name" FROM "migrations" ORDER BY "batch", "name"')).mappings().all()
|
|
191
|
+
return [row["name"] for row in rows]
|
|
192
|
+
|
|
193
|
+
def migrations_put(self, name, batch):
|
|
194
|
+
self._ensure_migrations_table()
|
|
195
|
+
sql = text('INSERT INTO "migrations" ("name", "batch") VALUES (:name, :batch)')
|
|
196
|
+
with self.client.begin() as connection:
|
|
197
|
+
connection.execute(sql, {"name": name, "batch": batch})
|
|
198
|
+
|
|
199
|
+
def migrations_delete(self, name):
|
|
200
|
+
self._ensure_migrations_table()
|
|
201
|
+
sql = text('DELETE FROM "migrations" WHERE "name" = :name')
|
|
202
|
+
with self.client.begin() as connection:
|
|
203
|
+
connection.execute(sql, {"name": name})
|
|
204
|
+
|
|
205
|
+
def migrations_max_batch(self):
|
|
206
|
+
self._ensure_migrations_table()
|
|
207
|
+
with self.client.connect() as connection:
|
|
208
|
+
row = connection.execute(text('SELECT MAX("batch") AS "batch" FROM "migrations"')).mappings().first()
|
|
209
|
+
if row is None or row["batch"] is None:
|
|
210
|
+
return 0
|
|
211
|
+
return int(row["batch"])
|
|
212
|
+
|
|
213
|
+
def migrations_batch_for(self, name):
|
|
214
|
+
self._ensure_migrations_table()
|
|
215
|
+
with self.client.connect() as connection:
|
|
216
|
+
row = connection.execute(text('SELECT "batch" FROM "migrations" WHERE "name" = :name'), {"name": name}).mappings().first()
|
|
217
|
+
if row is None:
|
|
218
|
+
return None
|
|
219
|
+
return int(row["batch"])
|
future/exceptions.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from future.request import Request
|
|
2
|
+
from future.response import Response
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HTTPException(Exception):
|
|
6
|
+
"""Raise from controllers/middleware to return an HTTP error response."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str = "Internal Server Error", status_code: int = 500, headers: dict[str, str] | None = None):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.message = message
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.headers = headers or {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorHandler:
|
|
16
|
+
def __init__(self, request: Request, response: Response):
|
|
17
|
+
self.request = request
|
|
18
|
+
self.response = response
|
|
19
|
+
|
|
20
|
+
def handle(self, exception: Exception) -> Response:
|
|
21
|
+
if isinstance(exception, HTTPException):
|
|
22
|
+
return self.response.json(
|
|
23
|
+
{"error": exception.message, "status_code": exception.status_code, "path": self.request.path},
|
|
24
|
+
status=exception.status_code,
|
|
25
|
+
headers=exception.headers or None,
|
|
26
|
+
)
|
|
27
|
+
return self.response.json({"error": "Internal Server Error", "status_code": 500, "path": self.request.path}, status=500)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from future.graphql.schema import queries, schema
|