future-framework 2.1.1__tar.gz → 3.0.0__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.0}/PKG-INFO +1 -1
  2. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/ClickhouseDatabase.py +48 -17
  3. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/ElasticsearchDatabase.py +36 -4
  4. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/MongoDBDatabase.py +26 -4
  5. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/MySQLDatabase.py +31 -4
  6. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/PostgresDatabase.py +32 -4
  7. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/RedisDatabase.py +67 -7
  8. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/SQLiteDatabase.py +34 -4
  9. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IDatabase.py +3 -0
  10. {future_framework-2.1.1 → future_framework-3.0.0}/future/migrations.py +60 -10
  11. {future_framework-2.1.1 → future_framework-3.0.0}/pyproject.toml +1 -1
  12. {future_framework-2.1.1 → future_framework-3.0.0}/LICENSE +0 -0
  13. {future_framework-2.1.1 → future_framework-3.0.0}/README.md +0 -0
  14. {future_framework-2.1.1 → future_framework-3.0.0}/future/__init__.py +0 -0
  15. {future_framework-2.1.1 → future_framework-3.0.0}/future/application.py +0 -0
  16. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/Auth0Authentication.py +0 -0
  17. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/AzureADAuthentication.py +0 -0
  18. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/BasicAuthentication.py +0 -0
  19. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/KerberosAuthentication.py +0 -0
  20. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/KeycloakAuthentication.py +0 -0
  21. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/OAuth2Authentication.py +0 -0
  22. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/OpenIdConnectAuthentication.py +0 -0
  23. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/SAMLAuthentication.py +0 -0
  24. {future_framework-2.1.1 → future_framework-3.0.0}/future/authentication/__init__.py +0 -0
  25. {future_framework-2.1.1 → future_framework-3.0.0}/future/cli.py +0 -0
  26. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/DebugController.py +0 -0
  27. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/GraphQLController.py +0 -0
  28. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/OpenAPIController.py +0 -0
  29. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/WebSocketController.py +0 -0
  30. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/WelcomeController.py +0 -0
  31. {future_framework-2.1.1 → future_framework-3.0.0}/future/controllers/__init__.py +0 -0
  32. {future_framework-2.1.1 → future_framework-3.0.0}/future/database.py +0 -0
  33. {future_framework-2.1.1 → future_framework-3.0.0}/future/databases/__init__.py +0 -0
  34. {future_framework-2.1.1 → future_framework-3.0.0}/future/exceptions.py +0 -0
  35. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IAuthentication.py +0 -0
  36. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IController.py +0 -0
  37. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IMiddleware.py +0 -0
  38. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IModel.py +0 -0
  39. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/IPlugin.py +0 -0
  40. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/ITask.py +0 -0
  41. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfaces/__init__.py +0 -0
  42. {future_framework-2.1.1 → future_framework-3.0.0}/future/interfacing.py +0 -0
  43. {future_framework-2.1.1 → future_framework-3.0.0}/future/lifespan.py +0 -0
  44. {future_framework-2.1.1 → future_framework-3.0.0}/future/logger.py +0 -0
  45. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/BruteforcePreventionMiddleware.py +0 -0
  46. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/CORSMiddleware.py +0 -0
  47. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/CSRFMiddleware.py +0 -0
  48. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/GZipMiddleware.py +0 -0
  49. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/HTAccessConfuserMiddleware.py +0 -0
  50. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/HeaderConfuserMiddleware.py +0 -0
  51. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/RateLimitMiddleware.py +0 -0
  52. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/ResponseCodeConfuserMiddleware.py +0 -0
  53. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/SQLiConfuserMiddleware.py +0 -0
  54. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/ScopeValidationMiddleware.py +0 -0
  55. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/SessionMiddleware.py +0 -0
  56. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/TestMiddlewareRequest.py +0 -0
  57. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/TestMiddlewareResponse.py +0 -0
  58. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/WebServerConfuserMiddleware.py +0 -0
  59. {future_framework-2.1.1 → future_framework-3.0.0}/future/middleware/__init__.py +0 -0
  60. {future_framework-2.1.1 → future_framework-3.0.0}/future/models/PostModel.py +0 -0
  61. {future_framework-2.1.1 → future_framework-3.0.0}/future/models/Query.py +0 -0
  62. {future_framework-2.1.1 → future_framework-3.0.0}/future/models/UserModel.py +0 -0
  63. {future_framework-2.1.1 → future_framework-3.0.0}/future/models/__init__.py +0 -0
  64. {future_framework-2.1.1 → future_framework-3.0.0}/future/openapi.py +0 -0
  65. {future_framework-2.1.1 → future_framework-3.0.0}/future/plugins/ElasticsearchPlugin.py +0 -0
  66. {future_framework-2.1.1 → future_framework-3.0.0}/future/plugins/__init__.py +0 -0
  67. {future_framework-2.1.1 → future_framework-3.0.0}/future/request.py +0 -0
  68. {future_framework-2.1.1 → future_framework-3.0.0}/future/response.py +0 -0
  69. {future_framework-2.1.1 → future_framework-3.0.0}/future/routing.py +0 -0
  70. {future_framework-2.1.1 → future_framework-3.0.0}/future/seeder.py +0 -0
  71. {future_framework-2.1.1 → future_framework-3.0.0}/future/settings.py +0 -0
  72. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckDNSTask.py +0 -0
  73. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckDiskUsageTask.py +0 -0
  74. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckHttpStatusTask.py +0 -0
  75. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckMemoryUsageTask.py +0 -0
  76. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckSSHBannerTask.py +0 -0
  77. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/CheckSystemUptimeTask.py +0 -0
  78. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/DailyBackupTask.py +0 -0
  79. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/SyncTaskExample.py +0 -0
  80. {future_framework-2.1.1 → future_framework-3.0.0}/future/tasks/__init__.py +0 -0
  81. {future_framework-2.1.1 → future_framework-3.0.0}/future/taskscheduler.py +0 -0
  82. {future_framework-2.1.1 → future_framework-3.0.0}/future/testclient.py +0 -0
  83. {future_framework-2.1.1 → future_framework-3.0.0}/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.0
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,31 @@ 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
+ common = [column.name for column in blueprint.columns if column.name in existing]
176
+ temporary = f"_future_migrate_{blueprint.name}"
177
+ backup = f"_future_backup_{blueprint.name}"
178
+ parts = self._schema_parts(blueprint)
159
179
  async with self.client.begin() as connection:
160
- await connection.execute(create_sql)
161
- return {"status": "created", "table": blueprint.name}
180
+ await connection.execute(text(f"DROP TABLE IF EXISTS `{temporary}`"))
181
+ await connection.execute(text(f"DROP TABLE IF EXISTS `{backup}`"))
182
+ await connection.execute(text(f"CREATE TABLE `{temporary}` ({', '.join(parts)})"))
183
+ if common:
184
+ columns = ", ".join(f"`{name}`" for name in common)
185
+ await connection.execute(text(f"INSERT INTO `{temporary}` ({columns}) SELECT {columns} FROM `{blueprint.name}`"))
186
+ await connection.execute(text(f"RENAME TABLE `{blueprint.name}` TO `{backup}`, `{temporary}` TO `{blueprint.name}`"))
187
+ await connection.execute(text(f"DROP TABLE `{backup}`"))
188
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(column.name for column in blueprint.columns)}
162
189
 
163
190
  async def schema_drop(self, name):
164
191
  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,31 @@ 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
+ common = [column.name for column in blueprint.columns if column.name in existing]
184
+ temporary = f"_future_{uuid4().hex[:8]}_{blueprint.name}"[:63]
185
+ parts, indexes = self._schema_parts(blueprint)
164
186
  async with self.client.begin() as connection:
165
- await connection.execute(create_sql)
187
+ await connection.execute(text(f'DROP TABLE IF EXISTS "{temporary}"'))
188
+ await connection.execute(text(f'CREATE TABLE "{temporary}" ({", ".join(parts)})'))
189
+ if common:
190
+ columns = ", ".join(f'"{name}"' for name in common)
191
+ await connection.execute(text(f'INSERT INTO "{temporary}" ({columns}) SELECT {columns} FROM "{blueprint.name}"'))
192
+ await connection.execute(text(f'DROP TABLE "{blueprint.name}"'))
193
+ await connection.execute(text(f'ALTER TABLE "{temporary}" RENAME TO "{blueprint.name}"'))
166
194
  for index_name in indexes:
167
195
  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}
196
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(column.name for column in blueprint.columns)}
169
197
 
170
198
  async def schema_drop(self, name):
171
199
  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,34 @@ 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
+ common = [column.name for column in blueprint.columns if column.name in existing]
182
+ temporary = f"_future_migrate_{blueprint.name}"
183
+ definitions, _ = self._schema_parts(blueprint)
184
+ _, indexes = self._schema_parts(blueprint, index_table=blueprint.name)
161
185
  async with self.client.begin() as connection:
162
- 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(definitions)})'))
188
+ if common:
189
+ columns = ", ".join(f'"{name}"' for name in common)
190
+ await connection.execute(text(f'INSERT INTO "{temporary}" ({columns}) SELECT {columns} FROM "{blueprint.name}"'))
191
+ await connection.execute(text(f'DROP TABLE "{blueprint.name}"'))
192
+ await connection.execute(text(f'ALTER TABLE "{temporary}" RENAME TO "{blueprint.name}"'))
163
193
  for index_sql in indexes:
164
194
  await connection.execute(text(index_sql))
165
- return {"status": "created", "table": blueprint.name}
195
+ return {"status": "updated", "table": blueprint.name, "columns": sorted(desired)}
166
196
 
167
197
  async def schema_drop(self, name):
168
198
  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
 
@@ -71,6 +72,8 @@ class Blueprint:
71
72
  connection = Database().get_connection(self.connection_name)
72
73
  if self.action == "create":
73
74
  await connection.schema_create(self)
75
+ elif self.action == "update":
76
+ await connection.schema_update(self)
74
77
  return False
75
78
 
76
79
  def _add(self, column):
@@ -112,6 +115,9 @@ class SchemaMeta(type):
112
115
  def create(cls, name):
113
116
  return Blueprint(name, cls._connection_name, action="create")
114
117
 
118
+ def update(cls, name):
119
+ return Blueprint(name, cls._connection_name, action="update")
120
+
115
121
  async def drop(cls, name):
116
122
  await Database().get_connection(cls._connection_name).schema_drop(name)
117
123
 
@@ -267,12 +273,49 @@ class MigrationGenerator:
267
273
  lines.append(" table.timestamps()")
268
274
  return lines
269
275
 
270
- def render(self, model):
276
+ def _schema_call(self, node, table):
277
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
278
+ return False
279
+ if node.func.attr not in ("create", "update") or not isinstance(node.func.value, ast.Name) or node.func.value.id != "Schema":
280
+ return False
281
+ return bool(node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == table)
282
+
283
+ def _previous_blueprint_lines(self, table):
284
+ directory = Path(self.migrations_path)
285
+ if not directory.exists():
286
+ return None
287
+ for path in reversed(sorted(directory.glob("*.py"))):
288
+ try:
289
+ source = path.read_text()
290
+ tree = ast.parse(source)
291
+ except (OSError, SyntaxError):
292
+ continue
293
+ for node in ast.walk(tree):
294
+ if not isinstance(node, ast.AsyncFunctionDef) or node.name != "up":
295
+ continue
296
+ for statement in node.body:
297
+ if not isinstance(statement, ast.AsyncWith):
298
+ continue
299
+ if not statement.items or not self._schema_call(statement.items[0].context_expr, table):
300
+ continue
301
+ lines = []
302
+ for item in statement.body:
303
+ if not isinstance(item, ast.Expr) or not isinstance(item.value, ast.Call):
304
+ continue
305
+ expression = (ast.get_source_segment(source, item) or ast.unparse(item)).strip()
306
+ if expression.startswith("table."):
307
+ lines.append(f" {expression}")
308
+ return lines
309
+ return None
310
+
311
+ def render(self, model, previous_lines=None):
271
312
  table = self.table_name(model)
272
- class_name = f"Create{inflection.camelize(table)}"
313
+ is_update = previous_lines is not None
314
+ action = "update" if is_update else "create"
315
+ class_name = f"{'Update' if is_update else 'Create'}{inflection.camelize(table)}"
273
316
  connection = getattr(model, "__connection__", "default")
274
317
  columns = "\n".join(self.blueprint_lines(model))
275
- return (
318
+ rendered = (
276
319
  "from future.migrations import Migration, Schema\n"
277
320
  "\n"
278
321
  "\n"
@@ -280,12 +323,14 @@ class MigrationGenerator:
280
323
  f' __connection__ = "{connection}"\n'
281
324
  "\n"
282
325
  " async def up(self):\n"
283
- f' async with Schema.create("{table}") as table:\n'
326
+ f' async with Schema.{action}("{table}") as table:\n'
284
327
  f"{columns}\n"
285
328
  "\n"
286
- " async def down(self):\n"
287
- f' await Schema.drop("{table}")\n'
288
329
  )
330
+ if not is_update:
331
+ return rendered + " async def down(self):\n" + f' await Schema.drop("{table}")\n'
332
+ previous = "\n".join(previous_lines)
333
+ return rendered + " async def down(self):\n" + f' async with Schema.update("{table}") as table:\n' + f"{previous}\n"
289
334
 
290
335
  def make(self, model_name=None):
291
336
  if model_name is None:
@@ -301,12 +346,17 @@ class MigrationGenerator:
301
346
 
302
347
  def write(self, model, stamp_offset=0):
303
348
  table = self.table_name(model)
304
- stamp = datetime.now().strftime("%Y_%m_%d_%H%M%S")
349
+ previous_lines = self._previous_blueprint_lines(table)
350
+ action = "update" if previous_lines is not None else "create"
351
+ stamp = datetime.now().strftime("%Y_%m_%d_%H%M%S_%f")
305
352
  if stamp_offset:
306
353
  stamp = f"{stamp}_{stamp_offset:02d}"
307
- file_name = f"{stamp}_create_{table}.py"
308
354
  directory = Path(self.migrations_path)
309
355
  directory.mkdir(parents=True, exist_ok=True)
310
- path = directory / file_name
311
- path.write_text(self.render(model))
356
+ path = directory / f"{stamp}_{action}_{table}.py"
357
+ collision = 1
358
+ while path.exists():
359
+ path = directory / f"{stamp}_{collision:02d}_{action}_{table}.py"
360
+ collision += 1
361
+ path.write_text(self.render(model, previous_lines=previous_lines))
312
362
  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.0"
4
4
  description = "ASGI-based framework for web APIs"
5
5
  authors = ["nicolaipre"]
6
6
  license = "LICENSE"