future-framework 2.1.1__tar.gz → 3.0.1__tar.gz

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 (83) hide show
  1. {future_framework-2.1.1 → future_framework-3.0.1}/PKG-INFO +1 -1
  2. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/ClickhouseDatabase.py +48 -17
  3. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/ElasticsearchDatabase.py +36 -4
  4. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/MongoDBDatabase.py +26 -4
  5. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/MySQLDatabase.py +48 -4
  6. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/PostgresDatabase.py +49 -4
  7. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/RedisDatabase.py +67 -7
  8. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/SQLiteDatabase.py +51 -4
  9. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IDatabase.py +3 -0
  10. {future_framework-2.1.1 → future_framework-3.0.1}/future/migrations.py +80 -10
  11. {future_framework-2.1.1 → future_framework-3.0.1}/pyproject.toml +1 -1
  12. {future_framework-2.1.1 → future_framework-3.0.1}/LICENSE +0 -0
  13. {future_framework-2.1.1 → future_framework-3.0.1}/README.md +0 -0
  14. {future_framework-2.1.1 → future_framework-3.0.1}/future/__init__.py +0 -0
  15. {future_framework-2.1.1 → future_framework-3.0.1}/future/application.py +0 -0
  16. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/Auth0Authentication.py +0 -0
  17. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/AzureADAuthentication.py +0 -0
  18. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/BasicAuthentication.py +0 -0
  19. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/KerberosAuthentication.py +0 -0
  20. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/KeycloakAuthentication.py +0 -0
  21. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/OAuth2Authentication.py +0 -0
  22. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/OpenIdConnectAuthentication.py +0 -0
  23. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/SAMLAuthentication.py +0 -0
  24. {future_framework-2.1.1 → future_framework-3.0.1}/future/authentication/__init__.py +0 -0
  25. {future_framework-2.1.1 → future_framework-3.0.1}/future/cli.py +0 -0
  26. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/DebugController.py +0 -0
  27. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/GraphQLController.py +0 -0
  28. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/OpenAPIController.py +0 -0
  29. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/WebSocketController.py +0 -0
  30. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/WelcomeController.py +0 -0
  31. {future_framework-2.1.1 → future_framework-3.0.1}/future/controllers/__init__.py +0 -0
  32. {future_framework-2.1.1 → future_framework-3.0.1}/future/database.py +0 -0
  33. {future_framework-2.1.1 → future_framework-3.0.1}/future/databases/__init__.py +0 -0
  34. {future_framework-2.1.1 → future_framework-3.0.1}/future/exceptions.py +0 -0
  35. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IAuthentication.py +0 -0
  36. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IController.py +0 -0
  37. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IMiddleware.py +0 -0
  38. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IModel.py +0 -0
  39. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/IPlugin.py +0 -0
  40. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/ITask.py +0 -0
  41. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfaces/__init__.py +0 -0
  42. {future_framework-2.1.1 → future_framework-3.0.1}/future/interfacing.py +0 -0
  43. {future_framework-2.1.1 → future_framework-3.0.1}/future/lifespan.py +0 -0
  44. {future_framework-2.1.1 → future_framework-3.0.1}/future/logger.py +0 -0
  45. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/BruteforcePreventionMiddleware.py +0 -0
  46. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/CORSMiddleware.py +0 -0
  47. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/CSRFMiddleware.py +0 -0
  48. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/GZipMiddleware.py +0 -0
  49. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/HTAccessConfuserMiddleware.py +0 -0
  50. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/HeaderConfuserMiddleware.py +0 -0
  51. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/RateLimitMiddleware.py +0 -0
  52. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/ResponseCodeConfuserMiddleware.py +0 -0
  53. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/SQLiConfuserMiddleware.py +0 -0
  54. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/ScopeValidationMiddleware.py +0 -0
  55. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/SessionMiddleware.py +0 -0
  56. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/TestMiddlewareRequest.py +0 -0
  57. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/TestMiddlewareResponse.py +0 -0
  58. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/WebServerConfuserMiddleware.py +0 -0
  59. {future_framework-2.1.1 → future_framework-3.0.1}/future/middleware/__init__.py +0 -0
  60. {future_framework-2.1.1 → future_framework-3.0.1}/future/models/PostModel.py +0 -0
  61. {future_framework-2.1.1 → future_framework-3.0.1}/future/models/Query.py +0 -0
  62. {future_framework-2.1.1 → future_framework-3.0.1}/future/models/UserModel.py +0 -0
  63. {future_framework-2.1.1 → future_framework-3.0.1}/future/models/__init__.py +0 -0
  64. {future_framework-2.1.1 → future_framework-3.0.1}/future/openapi.py +0 -0
  65. {future_framework-2.1.1 → future_framework-3.0.1}/future/plugins/ElasticsearchPlugin.py +0 -0
  66. {future_framework-2.1.1 → future_framework-3.0.1}/future/plugins/__init__.py +0 -0
  67. {future_framework-2.1.1 → future_framework-3.0.1}/future/request.py +0 -0
  68. {future_framework-2.1.1 → future_framework-3.0.1}/future/response.py +0 -0
  69. {future_framework-2.1.1 → future_framework-3.0.1}/future/routing.py +0 -0
  70. {future_framework-2.1.1 → future_framework-3.0.1}/future/seeder.py +0 -0
  71. {future_framework-2.1.1 → future_framework-3.0.1}/future/settings.py +0 -0
  72. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckDNSTask.py +0 -0
  73. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckDiskUsageTask.py +0 -0
  74. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckHttpStatusTask.py +0 -0
  75. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckMemoryUsageTask.py +0 -0
  76. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckSSHBannerTask.py +0 -0
  77. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/CheckSystemUptimeTask.py +0 -0
  78. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/DailyBackupTask.py +0 -0
  79. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/SyncTaskExample.py +0 -0
  80. {future_framework-2.1.1 → future_framework-3.0.1}/future/tasks/__init__.py +0 -0
  81. {future_framework-2.1.1 → future_framework-3.0.1}/future/taskscheduler.py +0 -0
  82. {future_framework-2.1.1 → future_framework-3.0.1}/future/testclient.py +0 -0
  83. {future_framework-2.1.1 → future_framework-3.0.1}/future/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: future-framework
3
- Version: 2.1.1
3
+ Version: 3.0.1
4
4
  Summary: ASGI-based framework for web APIs
5
5
  License: LICENSE
6
6
  License-File: LICENSE
@@ -123,23 +123,7 @@ class ClickhouseDatabase(IDatabase):
123
123
  definitions = []
124
124
  order_by = "tuple()"
125
125
  for column in blueprint.columns:
126
- if column.type == "string":
127
- ch_type = "String"
128
- elif column.type == "text":
129
- ch_type = "String"
130
- elif column.type == "integer":
131
- ch_type = "Int64"
132
- elif column.type == "float":
133
- ch_type = "Float64"
134
- elif column.type == "boolean":
135
- ch_type = "UInt8"
136
- elif column.type == "datetime":
137
- ch_type = "DateTime"
138
- else:
139
- raise ValueError(f"Unsupported column type: {column.type}")
140
- if column.is_nullable:
141
- ch_type = f"Nullable({ch_type})"
142
- definitions.append(f"`{column.name}` {ch_type}")
126
+ definitions.append(self._column_definition(column))
143
127
  if column.is_primary:
144
128
  order_by = f"`{column.name}`"
145
129
  create_sql = f"CREATE TABLE IF NOT EXISTS `{blueprint.name}` ({', '.join(definitions)}) ENGINE = MergeTree() ORDER BY {order_by}"
@@ -147,6 +131,53 @@ class ClickhouseDatabase(IDatabase):
147
131
  await cursor.execute(create_sql)
148
132
  return {"status": "created", "table": blueprint.name}
149
133
 
134
+ def _column_type(self, column):
135
+ if column.type in ("string", "text"):
136
+ ch_type = "String"
137
+ elif column.type == "integer":
138
+ ch_type = "Int64"
139
+ elif column.type == "float":
140
+ ch_type = "Float64"
141
+ elif column.type == "boolean":
142
+ ch_type = "UInt8"
143
+ elif column.type == "datetime":
144
+ ch_type = "DateTime"
145
+ else:
146
+ raise ValueError(f"Unsupported column type: {column.type}")
147
+ return f"Nullable({ch_type})" if column.is_nullable else ch_type
148
+
149
+ def _column_definition(self, column):
150
+ default = f" DEFAULT {column.default_value!r}" if column.default_value is not None else ""
151
+ return f"`{column.name}` {self._column_type(column)}{default}"
152
+
153
+ async def table_exists(self, name):
154
+ if self.client is None:
155
+ await self.connect()
156
+ async with self.client.cursor() as cursor:
157
+ await cursor.execute("SELECT count() FROM system.tables WHERE database = currentDatabase() AND name = %(name)s", {"name": name})
158
+ rows = await cursor.fetchall()
159
+ return bool(rows and rows[0][0])
160
+
161
+ async def schema_update(self, blueprint):
162
+ if self.client is None:
163
+ await self.connect()
164
+ if not await self.table_exists(blueprint.name):
165
+ return await self.schema_create(blueprint)
166
+ async with self.client.cursor() as cursor:
167
+ await cursor.execute("SELECT name, type FROM system.columns WHERE database = currentDatabase() AND table = %(table)s", {"table": blueprint.name})
168
+ rows = await cursor.fetchall()
169
+ existing = {row[0]: row[1] for row in rows}
170
+ desired = {column.name: column for column in blueprint.columns}
171
+ for name, column in desired.items():
172
+ definition = self._column_definition(column)
173
+ if name not in existing:
174
+ await cursor.execute(f"ALTER TABLE `{blueprint.name}` ADD COLUMN {definition}")
175
+ elif existing[name] != self._column_type(column):
176
+ await cursor.execute(f"ALTER TABLE `{blueprint.name}` MODIFY COLUMN {definition}")
177
+ for name in existing.keys() - desired.keys():
178
+ await cursor.execute(f"ALTER TABLE `{blueprint.name}` DROP COLUMN `{name}`")
179
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(desired)}
180
+
150
181
  async def schema_drop(self, name):
151
182
  if self.client is None:
152
183
  await self.connect()
@@ -115,6 +115,13 @@ class ElasticsearchDatabase(IDatabase):
115
115
  async def schema_create(self, blueprint):
116
116
  if self.client is None:
117
117
  await self.connect()
118
+ properties = self._mapping_properties(blueprint)
119
+ if await self.client.indices.exists(index=blueprint.name):
120
+ return {"status": "exists", "index": blueprint.name}
121
+ result = await self.client.indices.create(index=blueprint.name, mappings={"properties": properties})
122
+ return result
123
+
124
+ def _mapping_properties(self, blueprint):
118
125
  properties = {}
119
126
  for column in blueprint.columns:
120
127
  if column.type == "string" and (column.is_primary or column.name == "id"):
@@ -133,10 +140,35 @@ class ElasticsearchDatabase(IDatabase):
133
140
  properties[column.name] = {"type": "date"}
134
141
  else:
135
142
  raise ValueError(f"Unsupported column type: {column.type}")
136
- if await self.client.indices.exists(index=blueprint.name):
137
- return {"status": "exists", "index": blueprint.name}
138
- result = await self.client.indices.create(index=blueprint.name, mappings={"properties": properties})
139
- return result
143
+ return properties
144
+
145
+ async def schema_update(self, blueprint):
146
+ if self.client is None:
147
+ await self.connect()
148
+ if not await self.client.indices.exists(index=blueprint.name):
149
+ return await self.schema_create(blueprint)
150
+ desired = self._mapping_properties(blueprint)
151
+ mapping = await self.client.indices.get_mapping(index=blueprint.name)
152
+ current = mapping.get(blueprint.name, {}).get("mappings", {}).get("properties", {})
153
+ if current == desired:
154
+ return {"status": "unchanged", "index": blueprint.name}
155
+ additive = set(current).issubset(desired) and all(current[name] == desired[name] for name in current)
156
+ if additive:
157
+ await self.client.indices.put_mapping(index=blueprint.name, properties=desired)
158
+ return {"status": "updated", "index": blueprint.name, "properties": sorted(desired)}
159
+
160
+ temporary = f"_future_migrate_{blueprint.name}"
161
+ if await self.client.indices.exists(index=temporary):
162
+ await self.client.indices.delete(index=temporary)
163
+ await self.client.indices.create(index=temporary, mappings={"dynamic": False, "properties": desired})
164
+ keep = list(desired)
165
+ script = {"source": "ctx._source.keySet().removeIf(k -> !params.keep.contains(k))", "params": {"keep": keep}}
166
+ await self.client.reindex(body={"source": {"index": blueprint.name}, "dest": {"index": temporary}, "script": script}, wait_for_completion=True, refresh=True)
167
+ await self.client.indices.delete(index=blueprint.name)
168
+ await self.client.indices.create(index=blueprint.name, mappings={"dynamic": False, "properties": desired})
169
+ await self.client.reindex(body={"source": {"index": temporary}, "dest": {"index": blueprint.name}}, wait_for_completion=True, refresh=True)
170
+ await self.client.indices.delete(index=temporary)
171
+ return {"status": "updated", "index": blueprint.name, "properties": sorted(desired)}
140
172
 
141
173
  async def schema_drop(self, name):
142
174
  if self.client is None:
@@ -108,6 +108,14 @@ class MongoDBDatabase(IDatabase):
108
108
  async def schema_create(self, blueprint):
109
109
  if self.db is None:
110
110
  await self.connect()
111
+ validator = self._validator(blueprint)
112
+ if blueprint.name in await self.db.list_collection_names():
113
+ return {"status": "exists", "collection": blueprint.name}
114
+ await self.db.create_collection(blueprint.name, validator=validator)
115
+ await self._ensure_indexes(blueprint)
116
+ return {"status": "created", "collection": blueprint.name}
117
+
118
+ def _validator(self, blueprint):
111
119
  properties = {}
112
120
  required = []
113
121
  for column in blueprint.columns:
@@ -129,15 +137,29 @@ class MongoDBDatabase(IDatabase):
129
137
  validator = {"$jsonSchema": {"bsonType": "object", "properties": properties}}
130
138
  if required:
131
139
  validator["$jsonSchema"]["required"] = required
132
- if blueprint.name in await self.db.list_collection_names():
133
- return {"status": "exists", "collection": blueprint.name}
134
- await self.db.create_collection(blueprint.name, validator=validator)
140
+ return validator
141
+
142
+ async def _ensure_indexes(self, blueprint):
135
143
  for column in blueprint.columns:
136
144
  if column.is_unique or column.is_primary:
137
145
  await self.db[blueprint.name].create_index(column.name, unique=True)
138
146
  elif column.is_index:
139
147
  await self.db[blueprint.name].create_index(column.name)
140
- return {"status": "created", "collection": blueprint.name}
148
+
149
+ async def table_exists(self, name):
150
+ if self.db is None:
151
+ await self.connect()
152
+ return name in await self.db.list_collection_names()
153
+
154
+ async def schema_update(self, blueprint):
155
+ if self.db is None:
156
+ await self.connect()
157
+ if not await self.table_exists(blueprint.name):
158
+ return await self.schema_create(blueprint)
159
+ validator = self._validator(blueprint)
160
+ await self.db.command({"collMod": blueprint.name, "validator": validator})
161
+ await self._ensure_indexes(blueprint)
162
+ return {"status": "updated", "collection": blueprint.name, "properties": sorted(column.name for column in blueprint.columns)}
141
163
 
142
164
  async def schema_drop(self, name):
143
165
  if self.db is None:
@@ -123,6 +123,13 @@ class MySQLDatabase(IDatabase):
123
123
  async def schema_create(self, blueprint):
124
124
  if self.client is None:
125
125
  await self.connect()
126
+ parts = self._schema_parts(blueprint)
127
+ create_sql = text(f"CREATE TABLE IF NOT EXISTS `{blueprint.name}` ({', '.join(parts)})")
128
+ async with self.client.begin() as connection:
129
+ await connection.execute(create_sql)
130
+ return {"status": "created", "table": blueprint.name}
131
+
132
+ def _schema_parts(self, blueprint):
126
133
  definitions = []
127
134
  uniques = []
128
135
  indexes = []
@@ -154,11 +161,48 @@ class MySQLDatabase(IDatabase):
154
161
  uniques.append(f"UNIQUE KEY `unique_{column.name}` (`{column.name}`)")
155
162
  if column.is_index and not column.is_primary and not column.is_unique:
156
163
  indexes.append(f"KEY `index_{column.name}` (`{column.name}`)")
157
- parts = definitions + uniques + indexes
158
- create_sql = text(f"CREATE TABLE IF NOT EXISTS `{blueprint.name}` ({', '.join(parts)})")
164
+ return definitions + uniques + indexes
165
+
166
+ async def schema_update(self, blueprint):
167
+ if self.client is None:
168
+ await self.connect()
169
+ if not await self.table_exists(blueprint.name):
170
+ return await self.schema_create(blueprint)
171
+ sql = text("SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table ORDER BY ORDINAL_POSITION")
172
+ async with self.client.connect() as connection:
173
+ rows = (await connection.execute(sql, {"table": blueprint.name})).mappings().all()
174
+ existing = {row["COLUMN_NAME"] for row in rows}
175
+ temporary = f"_future_migrate_{blueprint.name}"
176
+ backup = f"_future_backup_{blueprint.name}"
177
+ parts = self._schema_parts(blueprint)
159
178
  async with self.client.begin() as connection:
160
- await connection.execute(create_sql)
161
- return {"status": "created", "table": blueprint.name}
179
+ await connection.execute(text(f"DROP TABLE IF EXISTS `{temporary}`"))
180
+ await connection.execute(text(f"DROP TABLE IF EXISTS `{backup}`"))
181
+ await connection.execute(text(f"CREATE TABLE `{temporary}` ({', '.join(parts)})"))
182
+ targets = []
183
+ sources = []
184
+ backfills = {}
185
+ for index, column in enumerate(blueprint.columns):
186
+ quoted = f"`{column.name}`"
187
+ if column.name in existing:
188
+ source = quoted
189
+ if not column.is_nullable:
190
+ key = f"future_backfill_{index}"
191
+ source = f"COALESCE({quoted}, :{key})"
192
+ backfills[key] = column.backfill_value()
193
+ elif not column.is_nullable:
194
+ key = f"future_backfill_{index}"
195
+ source = f":{key}"
196
+ backfills[key] = column.backfill_value()
197
+ else:
198
+ continue
199
+ targets.append(quoted)
200
+ sources.append(source)
201
+ if targets:
202
+ await connection.execute(text(f"INSERT INTO `{temporary}` ({', '.join(targets)}) SELECT {', '.join(sources)} FROM `{blueprint.name}`"), backfills)
203
+ await connection.execute(text(f"RENAME TABLE `{blueprint.name}` TO `{backup}`, `{temporary}` TO `{blueprint.name}`"))
204
+ await connection.execute(text(f"DROP TABLE `{backup}`"))
205
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(column.name for column in blueprint.columns)}
162
206
 
163
207
  async def schema_drop(self, name):
164
208
  if self.client is None:
@@ -3,6 +3,7 @@ from sqlalchemy import BigInteger, String, bindparam, text
3
3
  from sqlalchemy.dialects.postgresql import ARRAY
4
4
  from sqlalchemy.ext.asyncio import create_async_engine
5
5
  from urllib.parse import quote_plus
6
+ from uuid import uuid4
6
7
 
7
8
 
8
9
  class PostgresDatabase(IDatabase):
@@ -128,6 +129,15 @@ class PostgresDatabase(IDatabase):
128
129
  async def schema_create(self, blueprint):
129
130
  if self.client is None:
130
131
  await self.connect()
132
+ parts, indexes = self._schema_parts(blueprint)
133
+ create_sql = text(f"CREATE TABLE IF NOT EXISTS \"{blueprint.name}\" ({', '.join(parts)})")
134
+ async with self.client.begin() as connection:
135
+ await connection.execute(create_sql)
136
+ for index_name in indexes:
137
+ await connection.execute(text(f"CREATE INDEX IF NOT EXISTS \"index_{blueprint.name}_{index_name}\" ON \"{blueprint.name}\" (\"{index_name}\")"))
138
+ return {"status": "created", "table": blueprint.name}
139
+
140
+ def _schema_parts(self, blueprint):
131
141
  definitions = []
132
142
  uniques = []
133
143
  indexes = []
@@ -159,13 +169,48 @@ class PostgresDatabase(IDatabase):
159
169
  uniques.append(f"UNIQUE (\"{column.name}\")")
160
170
  if column.is_index and not column.is_primary and not column.is_unique:
161
171
  indexes.append(column.name)
162
- parts = definitions + uniques
163
- create_sql = text(f"CREATE TABLE IF NOT EXISTS \"{blueprint.name}\" ({', '.join(parts)})")
172
+ return definitions + uniques, indexes
173
+
174
+ async def schema_update(self, blueprint):
175
+ if self.client is None:
176
+ await self.connect()
177
+ if not await self.table_exists(blueprint.name):
178
+ return await self.schema_create(blueprint)
179
+ sql = text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = :table ORDER BY ordinal_position")
180
+ async with self.client.connect() as connection:
181
+ rows = (await connection.execute(sql, {"table": blueprint.name})).mappings().all()
182
+ existing = {row["column_name"] for row in rows}
183
+ temporary = f"_future_{uuid4().hex[:8]}_{blueprint.name}"[:63]
184
+ parts, indexes = self._schema_parts(blueprint)
164
185
  async with self.client.begin() as connection:
165
- await connection.execute(create_sql)
186
+ await connection.execute(text(f'DROP TABLE IF EXISTS "{temporary}"'))
187
+ await connection.execute(text(f'CREATE TABLE "{temporary}" ({", ".join(parts)})'))
188
+ targets = []
189
+ sources = []
190
+ backfills = {}
191
+ for index, column in enumerate(blueprint.columns):
192
+ quoted = f'"{column.name}"'
193
+ if column.name in existing:
194
+ source = quoted
195
+ if not column.is_nullable:
196
+ key = f"future_backfill_{index}"
197
+ source = f"COALESCE({quoted}, :{key})"
198
+ backfills[key] = column.backfill_value()
199
+ elif not column.is_nullable:
200
+ key = f"future_backfill_{index}"
201
+ source = f":{key}"
202
+ backfills[key] = column.backfill_value()
203
+ else:
204
+ continue
205
+ targets.append(quoted)
206
+ sources.append(source)
207
+ if targets:
208
+ await connection.execute(text(f'INSERT INTO "{temporary}" ({", ".join(targets)}) SELECT {", ".join(sources)} FROM "{blueprint.name}"'), backfills)
209
+ await connection.execute(text(f'DROP TABLE "{blueprint.name}"'))
210
+ await connection.execute(text(f'ALTER TABLE "{temporary}" RENAME TO "{blueprint.name}"'))
166
211
  for index_name in indexes:
167
212
  await connection.execute(text(f"CREATE INDEX IF NOT EXISTS \"index_{blueprint.name}_{index_name}\" ON \"{blueprint.name}\" (\"{index_name}\")"))
168
- return {"status": "created", "table": blueprint.name}
213
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(column.name for column in blueprint.columns)}
169
214
 
170
215
  async def schema_drop(self, name):
171
216
  if self.client is None:
@@ -27,6 +27,12 @@ class RedisDatabase(IDatabase):
27
27
  def _ids_key(self, table):
28
28
  return f"{table}:ids"
29
29
 
30
+ def _schema_key(self, table):
31
+ return f"future:schema:{table}"
32
+
33
+ def _migrations_key(self):
34
+ return "future:migrations"
35
+
30
36
  async def save(self, model):
31
37
  if self.client is None:
32
38
  await self.connect()
@@ -125,22 +131,76 @@ class RedisDatabase(IDatabase):
125
131
  return {"status": "updated", "id": doc_id}
126
132
 
127
133
  async def schema_create(self, blueprint):
128
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
134
+ if self.client is None:
135
+ await self.connect()
136
+ schema = self._schema_document(blueprint)
137
+ created = await self.client.set(self._schema_key(blueprint.name), json.dumps(schema, sort_keys=True, default=str), nx=True)
138
+ return {"status": "created" if created else "exists", "table": blueprint.name}
139
+
140
+ def _schema_document(self, blueprint):
141
+ return {
142
+ "name": blueprint.name,
143
+ "columns": [
144
+ {
145
+ "name": column.name,
146
+ "type": column.type,
147
+ "length": column.length,
148
+ "nullable": column.is_nullable,
149
+ "unique": column.is_unique,
150
+ "primary": column.is_primary,
151
+ "index": column.is_index,
152
+ "default": column.default_value,
153
+ }
154
+ for column in blueprint.columns
155
+ ],
156
+ }
157
+
158
+ async def table_exists(self, name):
159
+ if self.client is None:
160
+ await self.connect()
161
+ return bool(await self.client.exists(self._schema_key(name)))
162
+
163
+ async def schema_update(self, blueprint):
164
+ if self.client is None:
165
+ await self.connect()
166
+ await self.client.set(self._schema_key(blueprint.name), json.dumps(self._schema_document(blueprint), sort_keys=True, default=str))
167
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(column.name for column in blueprint.columns)}
129
168
 
130
169
  async def schema_drop(self, name):
131
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
170
+ if self.client is None:
171
+ await self.connect()
172
+ ids = await self.client.smembers(self._ids_key(name))
173
+ keys = [self._key(name, doc_id) for doc_id in ids]
174
+ keys.extend([self._ids_key(name), self._schema_key(name)])
175
+ if keys:
176
+ await self.client.delete(*keys)
177
+ return {"status": "dropped", "table": name}
132
178
 
133
179
  async def migrations_get(self):
134
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
180
+ if self.client is None:
181
+ await self.connect()
182
+ return list(await self.client.zrange(self._migrations_key(), 0, -1))
135
183
 
136
184
  async def migrations_put(self, name, batch):
137
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
185
+ if self.client is None:
186
+ await self.connect()
187
+ await self.client.zadd(self._migrations_key(), {name: int(batch)})
138
188
 
139
189
  async def migrations_delete(self, name):
140
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
190
+ if self.client is None:
191
+ await self.connect()
192
+ await self.client.zrem(self._migrations_key(), name)
141
193
 
142
194
  async def migrations_max_batch(self):
143
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
195
+ if self.client is None:
196
+ await self.connect()
197
+ rows = await self.client.zrevrange(self._migrations_key(), 0, 0, withscores=True)
198
+ if not rows:
199
+ return 0
200
+ return int(rows[0][1])
144
201
 
145
202
  async def migrations_batch_for(self, name):
146
- raise NotImplementedError("Redis has no schema; structural migrations are unsupported")
203
+ if self.client is None:
204
+ await self.connect()
205
+ score = await self.client.zscore(self._migrations_key(), name)
206
+ return None if score is None else int(score)
@@ -128,6 +128,15 @@ class SQLiteDatabase(IDatabase):
128
128
  async def schema_create(self, blueprint):
129
129
  if self.client is None:
130
130
  await self.connect()
131
+ definitions, indexes = self._schema_parts(blueprint)
132
+ create_sql = text(f'CREATE TABLE IF NOT EXISTS "{blueprint.name}" ({", ".join(definitions)})')
133
+ async with self.client.begin() as connection:
134
+ await connection.execute(create_sql)
135
+ for index_sql in indexes:
136
+ await connection.execute(text(index_sql))
137
+ return {"status": "created", "table": blueprint.name}
138
+
139
+ def _schema_parts(self, blueprint, index_table=None):
131
140
  definitions = []
132
141
  indexes = []
133
142
  for column in blueprint.columns:
@@ -156,13 +165,51 @@ class SQLiteDatabase(IDatabase):
156
165
  unique_sql = " UNIQUE" if column.is_unique and not column.is_primary else ""
157
166
  definitions.append(f'"{column.name}" {sql_type} {null_sql}{default_sql}{primary_sql}{unique_sql}')
158
167
  if column.is_index and not column.is_primary and not column.is_unique:
159
- indexes.append(f'CREATE INDEX IF NOT EXISTS "index_{blueprint.name}_{column.name}" ON "{blueprint.name}" ("{column.name}")')
160
- create_sql = text(f'CREATE TABLE IF NOT EXISTS "{blueprint.name}" ({", ".join(definitions)})')
168
+ table = index_table or blueprint.name
169
+ indexes.append(f'CREATE INDEX IF NOT EXISTS "index_{table}_{column.name}" ON "{table}" ("{column.name}")')
170
+ return definitions, indexes
171
+
172
+ async def schema_update(self, blueprint):
173
+ if self.client is None:
174
+ await self.connect()
175
+ if not await self.table_exists(blueprint.name):
176
+ return await self.schema_create(blueprint)
177
+ async with self.client.connect() as connection:
178
+ rows = (await connection.execute(text(f'PRAGMA table_info("{blueprint.name}")'))).mappings().all()
179
+ existing = {row["name"] for row in rows}
180
+ desired = {column.name for column in blueprint.columns}
181
+ temporary = f"_future_migrate_{blueprint.name}"
182
+ definitions, _ = self._schema_parts(blueprint)
183
+ _, indexes = self._schema_parts(blueprint, index_table=blueprint.name)
161
184
  async with self.client.begin() as connection:
162
- await connection.execute(create_sql)
185
+ await connection.execute(text(f'DROP TABLE IF EXISTS "{temporary}"'))
186
+ await connection.execute(text(f'CREATE TABLE "{temporary}" ({", ".join(definitions)})'))
187
+ targets = []
188
+ sources = []
189
+ backfills = {}
190
+ for index, column in enumerate(blueprint.columns):
191
+ quoted = f'"{column.name}"'
192
+ if column.name in existing:
193
+ source = quoted
194
+ if not column.is_nullable:
195
+ key = f"future_backfill_{index}"
196
+ source = f"COALESCE({quoted}, :{key})"
197
+ backfills[key] = column.backfill_value()
198
+ elif not column.is_nullable:
199
+ key = f"future_backfill_{index}"
200
+ source = f":{key}"
201
+ backfills[key] = column.backfill_value()
202
+ else:
203
+ continue
204
+ targets.append(quoted)
205
+ sources.append(source)
206
+ if targets:
207
+ await connection.execute(text(f'INSERT INTO "{temporary}" ({", ".join(targets)}) SELECT {", ".join(sources)} FROM "{blueprint.name}"'), backfills)
208
+ await connection.execute(text(f'DROP TABLE "{blueprint.name}"'))
209
+ await connection.execute(text(f'ALTER TABLE "{temporary}" RENAME TO "{blueprint.name}"'))
163
210
  for index_sql in indexes:
164
211
  await connection.execute(text(index_sql))
165
- return {"status": "created", "table": blueprint.name}
212
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(desired)}
166
213
 
167
214
  async def schema_drop(self, name):
168
215
  if self.client is None:
@@ -40,6 +40,9 @@ class IDatabase(Interface):
40
40
  async def schema_create(self, blueprint):
41
41
  raise NotImplementedError
42
42
 
43
+ async def schema_update(self, blueprint):
44
+ raise NotImplementedError
45
+
43
46
  async def schema_drop(self, name):
44
47
  raise NotImplementedError
45
48
 
@@ -1,6 +1,7 @@
1
1
  # Migrations — Orator/Masonite-style schema, discovery, and generation.
2
2
 
3
3
 
4
+ import ast
4
5
  import importlib.util
5
6
  import inflection
6
7
 
@@ -54,6 +55,21 @@ class Column:
54
55
  self.default_value = value
55
56
  return self
56
57
 
58
+ def backfill_value(self):
59
+ if self.default_value is not None:
60
+ return self.default_value
61
+ if self.type in ("string", "text"):
62
+ return ""
63
+ if self.type == "integer":
64
+ return 0
65
+ if self.type == "float":
66
+ return 0.0
67
+ if self.type == "boolean":
68
+ return False
69
+ if self.type == "datetime":
70
+ return datetime(1970, 1, 1)
71
+ raise ValueError(f"Unsupported column type: {self.type}")
72
+
57
73
 
58
74
  class Blueprint:
59
75
  def __init__(self, name, connection_name, action="create"):
@@ -71,6 +87,8 @@ class Blueprint:
71
87
  connection = Database().get_connection(self.connection_name)
72
88
  if self.action == "create":
73
89
  await connection.schema_create(self)
90
+ elif self.action == "update":
91
+ await connection.schema_update(self)
74
92
  return False
75
93
 
76
94
  def _add(self, column):
@@ -112,6 +130,9 @@ class SchemaMeta(type):
112
130
  def create(cls, name):
113
131
  return Blueprint(name, cls._connection_name, action="create")
114
132
 
133
+ def update(cls, name):
134
+ return Blueprint(name, cls._connection_name, action="update")
135
+
115
136
  async def drop(cls, name):
116
137
  await Database().get_connection(cls._connection_name).schema_drop(name)
117
138
 
@@ -262,17 +283,59 @@ class MigrationGenerator:
262
283
  for field, annotation in annotations.items():
263
284
  line = self.column_line(field, annotation)
264
285
  if line is not None:
286
+ default_value = vars(model).get(field)
287
+ if default_value is not None:
288
+ if isinstance(default_value, datetime):
289
+ default_value = default_value.isoformat(sep=" ")
290
+ line += f".default({default_value!r})"
265
291
  lines.append(f" {line}")
266
292
  if use_timestamps:
267
293
  lines.append(" table.timestamps()")
268
294
  return lines
269
295
 
270
- def render(self, model):
296
+ def _schema_call(self, node, table):
297
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
298
+ return False
299
+ if node.func.attr not in ("create", "update") or not isinstance(node.func.value, ast.Name) or node.func.value.id != "Schema":
300
+ return False
301
+ return bool(node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == table)
302
+
303
+ def _previous_blueprint_lines(self, table):
304
+ directory = Path(self.migrations_path)
305
+ if not directory.exists():
306
+ return None
307
+ for path in reversed(sorted(directory.glob("*.py"))):
308
+ try:
309
+ source = path.read_text()
310
+ tree = ast.parse(source)
311
+ except (OSError, SyntaxError):
312
+ continue
313
+ for node in ast.walk(tree):
314
+ if not isinstance(node, ast.AsyncFunctionDef) or node.name != "up":
315
+ continue
316
+ for statement in node.body:
317
+ if not isinstance(statement, ast.AsyncWith):
318
+ continue
319
+ if not statement.items or not self._schema_call(statement.items[0].context_expr, table):
320
+ continue
321
+ lines = []
322
+ for item in statement.body:
323
+ if not isinstance(item, ast.Expr) or not isinstance(item.value, ast.Call):
324
+ continue
325
+ expression = (ast.get_source_segment(source, item) or ast.unparse(item)).strip()
326
+ if expression.startswith("table."):
327
+ lines.append(f" {expression}")
328
+ return lines
329
+ return None
330
+
331
+ def render(self, model, previous_lines=None):
271
332
  table = self.table_name(model)
272
- class_name = f"Create{inflection.camelize(table)}"
333
+ is_update = previous_lines is not None
334
+ action = "update" if is_update else "create"
335
+ class_name = f"{'Update' if is_update else 'Create'}{inflection.camelize(table)}"
273
336
  connection = getattr(model, "__connection__", "default")
274
337
  columns = "\n".join(self.blueprint_lines(model))
275
- return (
338
+ rendered = (
276
339
  "from future.migrations import Migration, Schema\n"
277
340
  "\n"
278
341
  "\n"
@@ -280,12 +343,14 @@ class MigrationGenerator:
280
343
  f' __connection__ = "{connection}"\n'
281
344
  "\n"
282
345
  " async def up(self):\n"
283
- f' async with Schema.create("{table}") as table:\n'
346
+ f' async with Schema.{action}("{table}") as table:\n'
284
347
  f"{columns}\n"
285
348
  "\n"
286
- " async def down(self):\n"
287
- f' await Schema.drop("{table}")\n'
288
349
  )
350
+ if not is_update:
351
+ return rendered + " async def down(self):\n" + f' await Schema.drop("{table}")\n'
352
+ previous = "\n".join(previous_lines)
353
+ return rendered + " async def down(self):\n" + f' async with Schema.update("{table}") as table:\n' + f"{previous}\n"
289
354
 
290
355
  def make(self, model_name=None):
291
356
  if model_name is None:
@@ -301,12 +366,17 @@ class MigrationGenerator:
301
366
 
302
367
  def write(self, model, stamp_offset=0):
303
368
  table = self.table_name(model)
304
- stamp = datetime.now().strftime("%Y_%m_%d_%H%M%S")
369
+ previous_lines = self._previous_blueprint_lines(table)
370
+ action = "update" if previous_lines is not None else "create"
371
+ stamp = datetime.now().strftime("%Y_%m_%d_%H%M%S_%f")
305
372
  if stamp_offset:
306
373
  stamp = f"{stamp}_{stamp_offset:02d}"
307
- file_name = f"{stamp}_create_{table}.py"
308
374
  directory = Path(self.migrations_path)
309
375
  directory.mkdir(parents=True, exist_ok=True)
310
- path = directory / file_name
311
- path.write_text(self.render(model))
376
+ path = directory / f"{stamp}_{action}_{table}.py"
377
+ collision = 1
378
+ while path.exists():
379
+ path = directory / f"{stamp}_{collision:02d}_{action}_{table}.py"
380
+ collision += 1
381
+ path.write_text(self.render(model, previous_lines=previous_lines))
312
382
  return str(path)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "future-framework"
3
- version = "2.1.1"
3
+ version = "3.0.1"
4
4
  description = "ASGI-based framework for web APIs"
5
5
  authors = ["nicolaipre"]
6
6
  license = "LICENSE"