clickhouse-orm 3.0.1__py2.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.
- clickhouse_orm/__init__.py +14 -0
- clickhouse_orm/database.py +457 -0
- clickhouse_orm/engines.py +346 -0
- clickhouse_orm/fields.py +665 -0
- clickhouse_orm/funcs.py +1841 -0
- clickhouse_orm/migrations.py +287 -0
- clickhouse_orm/models.py +617 -0
- clickhouse_orm/query.py +701 -0
- clickhouse_orm/system_models.py +170 -0
- clickhouse_orm/utils.py +176 -0
- clickhouse_orm-3.0.1.dist-info/METADATA +90 -0
- clickhouse_orm-3.0.1.dist-info/RECORD +14 -0
- clickhouse_orm-3.0.1.dist-info/WHEEL +5 -0
- clickhouse_orm-3.0.1.dist-info/licenses/LICENSE +27 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .engines import MergeTree
|
|
6
|
+
from .fields import DateField, StringField
|
|
7
|
+
from .models import BufferModel, Model
|
|
8
|
+
from .utils import get_subclass_names
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("migrations")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Operation:
|
|
14
|
+
"""
|
|
15
|
+
Base class for migration operations.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def apply(self, database):
|
|
19
|
+
raise NotImplementedError() # pragma: no cover
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelOperation(Operation):
|
|
23
|
+
"""
|
|
24
|
+
Base class for migration operations that work on a specific model.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, model_class):
|
|
28
|
+
"""
|
|
29
|
+
Initializer.
|
|
30
|
+
"""
|
|
31
|
+
self.model_class = model_class
|
|
32
|
+
self.table_name = model_class.table_name()
|
|
33
|
+
|
|
34
|
+
def _alter_table(self, database, cmd):
|
|
35
|
+
"""
|
|
36
|
+
Utility for running ALTER TABLE commands.
|
|
37
|
+
"""
|
|
38
|
+
cmd = "ALTER TABLE $db.`%s` %s" % (self.table_name, cmd)
|
|
39
|
+
logger.debug(cmd)
|
|
40
|
+
database.raw(cmd)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CreateTable(ModelOperation):
|
|
44
|
+
"""
|
|
45
|
+
A migration operation that creates a table for a given model class.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def apply(self, database):
|
|
49
|
+
logger.info(" Create table %s", self.table_name)
|
|
50
|
+
if issubclass(self.model_class, BufferModel):
|
|
51
|
+
database.create_table(self.model_class.engine.main_model)
|
|
52
|
+
database.create_table(self.model_class)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AlterTable(ModelOperation):
|
|
56
|
+
"""
|
|
57
|
+
A migration operation that compares the table of a given model class to
|
|
58
|
+
the model's fields, and alters the table to match the model. The operation can:
|
|
59
|
+
- add new columns
|
|
60
|
+
- drop obsolete columns
|
|
61
|
+
- modify column types
|
|
62
|
+
Default values are not altered by this operation.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def _get_table_fields(self, database):
|
|
66
|
+
query = "DESC `%s`.`%s`" % (database.db_name, self.table_name)
|
|
67
|
+
return [(row.name, row.type) for row in database.select(query)]
|
|
68
|
+
|
|
69
|
+
def apply(self, database):
|
|
70
|
+
logger.info(" Alter table %s", self.table_name)
|
|
71
|
+
|
|
72
|
+
# Note that MATERIALIZED and ALIAS fields are always at the end of the DESC,
|
|
73
|
+
# ADD COLUMN ... AFTER doesn't affect it
|
|
74
|
+
table_fields = dict(self._get_table_fields(database))
|
|
75
|
+
|
|
76
|
+
# Identify fields that were deleted from the model
|
|
77
|
+
deleted_fields = set(table_fields.keys()) - set(self.model_class.fields())
|
|
78
|
+
for name in deleted_fields:
|
|
79
|
+
logger.info(" Drop column %s", name)
|
|
80
|
+
self._alter_table(database, "DROP COLUMN %s" % name)
|
|
81
|
+
del table_fields[name]
|
|
82
|
+
|
|
83
|
+
# Identify fields that were added to the model
|
|
84
|
+
prev_name = None
|
|
85
|
+
for name, field in self.model_class.fields().items():
|
|
86
|
+
is_regular_field = not (field.materialized or field.alias)
|
|
87
|
+
if name not in table_fields:
|
|
88
|
+
logger.info(" Add column %s", name)
|
|
89
|
+
cmd = "ADD COLUMN %s %s" % (name, field.get_sql(db=database))
|
|
90
|
+
if is_regular_field:
|
|
91
|
+
if prev_name:
|
|
92
|
+
cmd += " AFTER %s" % prev_name
|
|
93
|
+
else:
|
|
94
|
+
cmd += " FIRST"
|
|
95
|
+
self._alter_table(database, cmd)
|
|
96
|
+
|
|
97
|
+
if is_regular_field:
|
|
98
|
+
# ALIAS and MATERIALIZED fields are not stored in the database, and raise DatabaseError
|
|
99
|
+
# (no AFTER column). So we will skip them
|
|
100
|
+
prev_name = name
|
|
101
|
+
|
|
102
|
+
# Identify fields whose type was changed
|
|
103
|
+
# The order of class attributes can be changed any time, so we can't count on it
|
|
104
|
+
# Secondly, MATERIALIZED and ALIAS fields are always at the end of the DESC, so we can't expect them to save
|
|
105
|
+
# attribute position. Watch https://github.com/Infinidat/clickhouse_orm/issues/47
|
|
106
|
+
model_fields = {
|
|
107
|
+
name: field.get_sql(with_default_expression=False, db=database)
|
|
108
|
+
for name, field in self.model_class.fields().items()
|
|
109
|
+
}
|
|
110
|
+
for field_name, field_sql in self._get_table_fields(database):
|
|
111
|
+
# All fields must have been created and dropped by this moment
|
|
112
|
+
assert field_name in model_fields, "Model fields and table columns in disagreement"
|
|
113
|
+
|
|
114
|
+
if field_sql != model_fields[field_name]:
|
|
115
|
+
logger.info(
|
|
116
|
+
" Change type of column %s from %s to %s", field_name, field_sql, model_fields[field_name]
|
|
117
|
+
)
|
|
118
|
+
self._alter_table(database, "MODIFY COLUMN %s %s" % (field_name, model_fields[field_name]))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AlterTableWithBuffer(ModelOperation):
|
|
122
|
+
"""
|
|
123
|
+
A migration operation for altering a buffer table and its underlying on-disk table.
|
|
124
|
+
The buffer table is dropped, the on-disk table is altered, and then the buffer table
|
|
125
|
+
is re-created.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def apply(self, database):
|
|
129
|
+
if issubclass(self.model_class, BufferModel):
|
|
130
|
+
DropTable(self.model_class).apply(database)
|
|
131
|
+
AlterTable(self.model_class.engine.main_model).apply(database)
|
|
132
|
+
CreateTable(self.model_class).apply(database)
|
|
133
|
+
else:
|
|
134
|
+
AlterTable(self.model_class).apply(database)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DropTable(ModelOperation):
|
|
138
|
+
"""
|
|
139
|
+
A migration operation that drops the table of a given model class.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def apply(self, database):
|
|
143
|
+
logger.info(" Drop table %s", self.table_name)
|
|
144
|
+
database.drop_table(self.model_class)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AlterConstraints(ModelOperation):
|
|
148
|
+
"""
|
|
149
|
+
A migration operation that adds new constraints from the model to the database
|
|
150
|
+
table, and drops obsolete ones. Constraints are identified by their names, so
|
|
151
|
+
a change in an existing constraint will not be detected unless its name was changed too.
|
|
152
|
+
ClickHouse does not check that the constraints hold for existing data in the table.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def apply(self, database):
|
|
156
|
+
logger.info(" Alter constraints for %s", self.table_name)
|
|
157
|
+
existing = self._get_constraint_names(database)
|
|
158
|
+
no_longer_needed = existing - {c.name for c in self.model_class._constraints.values()}
|
|
159
|
+
# Drop old constraints first as they can conflict
|
|
160
|
+
for name in no_longer_needed:
|
|
161
|
+
logger.info(" Drop constraint %s", name)
|
|
162
|
+
self._alter_table(database, "DROP CONSTRAINT `%s`" % name)
|
|
163
|
+
|
|
164
|
+
# Add any new constraints
|
|
165
|
+
for constraint in self.model_class._constraints.values():
|
|
166
|
+
# Check if it's a new constraint
|
|
167
|
+
if constraint.name not in existing:
|
|
168
|
+
logger.info(" Add constraint %s", constraint.name)
|
|
169
|
+
self._alter_table(database, "ADD %s" % constraint.create_table_sql())
|
|
170
|
+
|
|
171
|
+
def _get_constraint_names(self, database):
|
|
172
|
+
"""
|
|
173
|
+
Returns a set containing the names of existing constraints in the table.
|
|
174
|
+
"""
|
|
175
|
+
import re
|
|
176
|
+
|
|
177
|
+
table_def = database.raw("SHOW CREATE TABLE $db.`%s`" % self.table_name)
|
|
178
|
+
matches = re.findall(r"\sCONSTRAINT\s+`?(.+?)`?\s+CHECK\s", table_def)
|
|
179
|
+
return set(matches)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AlterIndexes(ModelOperation):
|
|
183
|
+
"""
|
|
184
|
+
A migration operation that adds new indexes from the model to the database
|
|
185
|
+
table, and drops obsolete ones. Indexes are identified by their names, so
|
|
186
|
+
a change in an existing index will not be detected unless its name was changed too.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, model_class, reindex=False):
|
|
190
|
+
"""
|
|
191
|
+
Initializer.
|
|
192
|
+
By default ClickHouse does not build indexes over existing data, only for
|
|
193
|
+
new data. Passing `reindex=True` will run `OPTIMIZE TABLE` in order to build
|
|
194
|
+
the indexes over the existing data.
|
|
195
|
+
"""
|
|
196
|
+
super().__init__(model_class)
|
|
197
|
+
self.reindex = reindex
|
|
198
|
+
|
|
199
|
+
def apply(self, database):
|
|
200
|
+
logger.info(" Alter indexes for %s", self.table_name)
|
|
201
|
+
existing = self._get_index_names(database)
|
|
202
|
+
logger.info(existing)
|
|
203
|
+
# Go over indexes in the model
|
|
204
|
+
for index in self.model_class._indexes.values():
|
|
205
|
+
# Check if it's a new index
|
|
206
|
+
if index.name not in existing:
|
|
207
|
+
logger.info(" Add index %s", index.name)
|
|
208
|
+
self._alter_table(database, "ADD %s" % index.create_table_sql())
|
|
209
|
+
else:
|
|
210
|
+
existing.remove(index.name)
|
|
211
|
+
# Remaining indexes in `existing` are obsolete
|
|
212
|
+
for name in existing:
|
|
213
|
+
logger.info(" Drop index %s", name)
|
|
214
|
+
self._alter_table(database, "DROP INDEX `%s`" % name)
|
|
215
|
+
# Reindex
|
|
216
|
+
if self.reindex:
|
|
217
|
+
logger.info(" Build indexes on table")
|
|
218
|
+
database.raw("OPTIMIZE TABLE $db.`%s` FINAL" % self.table_name)
|
|
219
|
+
|
|
220
|
+
def _get_index_names(self, database):
|
|
221
|
+
"""
|
|
222
|
+
Returns a set containing the names of existing indexes in the table.
|
|
223
|
+
"""
|
|
224
|
+
import re
|
|
225
|
+
|
|
226
|
+
table_def = database.raw("SHOW CREATE TABLE $db.`%s`" % self.table_name)
|
|
227
|
+
matches = re.findall(r"\sINDEX\s+`?(.+?)`?\s+", table_def)
|
|
228
|
+
return set(matches)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class RunPython(Operation):
|
|
232
|
+
"""
|
|
233
|
+
A migration operation that executes a Python function.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, func):
|
|
237
|
+
"""
|
|
238
|
+
Initializer. The given Python function will be called with a single
|
|
239
|
+
argument - the Database instance to apply the migration to.
|
|
240
|
+
"""
|
|
241
|
+
assert callable(func), "'func' argument must be function"
|
|
242
|
+
self._func = func
|
|
243
|
+
|
|
244
|
+
def apply(self, database):
|
|
245
|
+
logger.info(" Executing python operation %s", self._func.__name__)
|
|
246
|
+
self._func(database)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class RunSQL(Operation):
|
|
250
|
+
"""
|
|
251
|
+
A migration operation that executes arbitrary SQL statements.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self, sql):
|
|
255
|
+
"""
|
|
256
|
+
Initializer. The given sql argument must be a valid SQL statement or
|
|
257
|
+
list of statements.
|
|
258
|
+
"""
|
|
259
|
+
if isinstance(sql, str):
|
|
260
|
+
sql = [sql]
|
|
261
|
+
assert isinstance(sql, list), "'sql' argument must be string or list of strings"
|
|
262
|
+
self._sql = sql
|
|
263
|
+
|
|
264
|
+
def apply(self, database):
|
|
265
|
+
logger.info(" Executing raw SQL operations")
|
|
266
|
+
for item in self._sql:
|
|
267
|
+
database.raw(item)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class MigrationHistory(Model):
|
|
271
|
+
"""
|
|
272
|
+
A model for storing which migrations were already applied to the containing database.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
package_name = StringField()
|
|
276
|
+
module_name = StringField()
|
|
277
|
+
applied = DateField()
|
|
278
|
+
|
|
279
|
+
engine = MergeTree("applied", ("package_name", "module_name"))
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def table_name(cls):
|
|
283
|
+
return "infi_clickhouse_orm_migrations"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# Expose only relevant classes in import *
|
|
287
|
+
__all__ = get_subclass_names(locals(), Operation)
|