No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

mysql.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. # -*- coding: utf-8 -*-
  2. import copy
  3. import _mysql as mysqlclient
  4. import _mysql_exceptions
  5. import EditorialModel
  6. # The global MH algorithm is as follow :
  7. # A create_table(table_name, pk_name, pk_opt) method that create a table
  8. # with one pk field
  9. # An add_column(table_name, field_name, field_opt) method that add a column to a table
  10. #
  11. # The create_default_table method will call both methods to create the object and relation tables
  12. #
  13. # Supported operations :
  14. # - EmClass creation
  15. # - EmClass deletion (untested)
  16. # - EmField creation
  17. # - EmField deletion (untested)
  18. #
  19. # Unsupported operations :
  20. # - EmClass rename
  21. # - EmField rename
  22. ## @brief Modify a MySQL database given editorial model changes
  23. class MysqlMigrationHandler(EditorialModel.migrationhandler.dummy.DummyMigrationHandler):
  24. _object_tname = 'object'
  25. _relation_tname = 'relation'
  26. ## @brief Construct a MysqlMigrationHandler
  27. # @param host str : The db host
  28. # @param user str : The db user
  29. # @param password str : The db password
  30. # @param db str : The db name
  31. def __init__(self, host, user, password, db, db_engine = 'InnoDB', foreign_keys = True, debug = False, dryrun = False, drop_if_exists = False):
  32. #Connect to MySQL
  33. self.db = mysqlclient.connect(host=host, user=user, passwd=password, db=db)
  34. self.debug = debug
  35. self.dryrun = dryrun
  36. self.db_engine = db_engine
  37. self.foreign_keys = foreign_keys if db_engine == 'InnoDB' else False
  38. self.drop_if_exists = drop_if_exists
  39. #Create default tables
  40. self._create_default_tables(self.drop_if_exists)
  41. pass
  42. ## @brief Modify the db given an EM change
  43. def register_change(self, em, uid, initial_state, new_state, engine = None):
  44. if engine is None:
  45. engine = self.db_engine
  46. if isinstance(em.component(uid), EditorialModel.classes.EmClass):
  47. if initial_state is None:
  48. #EmClass creation
  49. self.create_emclass_table(em, uid, engine)
  50. elif new_state is None:
  51. #EmClass deletion
  52. self.delete_emclass_table(em, uid)
  53. elif isinstance(em.component(uid), EditorialModel.fields.EmField):
  54. emfield = em.component(uid)
  55. if initial_state is None:
  56. #EmField creation
  57. if not(emfield.name in EditorialModel.classtypes.common_fields.keys()):
  58. self.add_col_from_emfield(em,uid)
  59. elif new_state is None:
  60. #EmField deletion
  61. if not (emfield.name in EditorialModel.classtypes.common_fields.keys()):
  62. self.del_col_from_emfield(em, uid)
  63. pass
  64. pass
  65. def register_model_state(self, em, state_hash):
  66. pass
  67. ## @brief Exec a query
  68. def _query(self, query):
  69. if self.debug:
  70. print(query+"\n")
  71. if not self.dryrun:
  72. self.db.query(query)
  73. ## @brief Given an EmField uid add a column to the corresponding table
  74. # @param em Model : A Model instance
  75. # @param uid int : An EmField uid
  76. # @return None
  77. def add_col_from_emfield(self, em, uid):
  78. emfield = em.component(uid)
  79. if not isinstance(emfield, EditorialModel.fields.EmField):
  80. raise ValueError("The given uid is not an EmField uid")
  81. emclass = emfield.em_class
  82. tname = self._emclass2table_name(emclass)
  83. self._add_column(tname, emfield.name, emfield.fieldtype_instance())
  84. # Refresh the table triggers
  85. cols_l = self._class2cols(emclass)
  86. self._generate_triggers(tname, cols_l)
  87. ## @brief Given a class uid create the coressponding table
  88. def create_emclass_table(self, em, uid, engine):
  89. emclass = em.component(uid)
  90. if not isinstance(emclass, EditorialModel.classes.EmClass):
  91. raise ValueError("The given uid is not an EmClass uid")
  92. pkname, pktype = self._common_field_pk
  93. table_name = self._emclass2table_name(emclass)
  94. self._create_table(table_name, pkname, pktype, engine=engine)
  95. if self.foreign_keys:
  96. self._add_fk(table_name, self._object_tname, pkname, pkname)
  97. ## @brief Given an EmClass uid delete the corresponding table
  98. def delete_emclass_table(self, em, uid):
  99. emclass = emcomponent(uid)
  100. if not isinstance(emclass, EditorialModel.classes.EmClass):
  101. raise ValueError("The give uid is not an EmClass uid")
  102. tname = self._idname_escape(self._emclass2table_name(emclass.name))
  103. # Delete the table triggers to prevent errors
  104. self._generate_triggers(tname, dict())
  105. self._query("""DROP TABLE {table_name};""".format(table_name = tname))
  106. ## @brief Given an EmField delete the corresponding column
  107. # @param em Model : an @ref EditorialModel.model.Model instance
  108. # @param uid int : an EmField uid
  109. def delete_col_from_emfield(self, em, uid):
  110. emfield = em.component(uid)
  111. if not isinstance(emfield, EditorialModel.fields.EmField):
  112. raise ValueError("The given uid is not an EmField uid")
  113. emclass = emfield.em_class
  114. tname = self._emclass2table_name(emclass)
  115. # Delete the table triggers to prevent errors
  116. self._generate_triggers(tname, dict())
  117. self._del_column(tname, emfield.name)
  118. # Refresh the table triggers
  119. cols_ls = self._class2cols(emclass)
  120. self._generate_triggers(tname, cols_l)
  121. ## @brief Delete a column from a table
  122. # @param tname str : The table name
  123. # @param fname str : The column name
  124. def _del_column(self, tname, fname):
  125. tname = self._idname_escape(tname)
  126. fname = self._idname_escape(fname)
  127. self._query("""ALTER TABLE {table_name} DROP COLUMN {col_name};""".format(table_name = tname, col_name = fname))
  128. ## @brief Construct a table name given an EmClass instance
  129. # @param emclass EmClass : An EmClass instance
  130. # @return a table name
  131. def _emclass2table_name(self, emclass):
  132. return "class_%s"%emclass.name
  133. ## @brief Generate a columns_fieldtype dict given an EmClass uid
  134. # @param emclass EmClass : An EmClass instance
  135. # @return A dict with column name as key and EmFieldType instance as value
  136. def _class2cols(self, emclass):
  137. if not isinstance(emclass, EditorialModel.classes.EmClass):
  138. raise ValueError("The given uid is not an EmClass uid")
  139. return { f.name: f.fieldtype_instance() for f in emclass.fields() }
  140. ## @brief Create object and relations tables
  141. # @param drop_if_exist bool : If true drop tables if exists
  142. def _create_default_tables(self, drop_if_exist = False):
  143. if_exists = 'drop' if drop_if_exist else 'nothing'
  144. #Object tablea
  145. tname = self._object_tname
  146. pk_name, pk_ftype = self._common_field_pk
  147. self._create_table(tname, pk_name, pk_ftype, engine=self.db_engine, if_exists = if_exists)
  148. #Adding columns
  149. cols = { fname: self._common_field_to_ftype(fname) for fname in EditorialModel.classtypes.common_fields }
  150. for fname, ftype in cols.items():
  151. if fname != pk_name:
  152. self._add_column(tname, fname, ftype)
  153. #Creating triggers
  154. self._generate_triggers(tname, cols)
  155. #Relation table
  156. tname = self._relation_tname
  157. pk_name, pk_ftype = self._relation_pk
  158. self._create_table(tname, pk_name, pk_ftype, engine = self.db_engine, if_exists = if_exists)
  159. #Adding columns
  160. for fname, ftype in self._relation_cols.items():
  161. self._add_column(tname, fname, ftype)
  162. #Creating triggers
  163. self._generate_triggers(tname, self._relation_cols)
  164. ## @return true if the name changes
  165. def _name_change(self, initial_state, new_state):
  166. return 'name' in initial_state and initial_state['name'] != new_state['name']
  167. ## @brief Create a table with primary key
  168. # @param table_name str : table name
  169. # @param pk_name str : pk column name
  170. # @param pk_specs str : see @ref _field_to_sql()
  171. # @param engine str : The engine to use with this table
  172. # @param charset str : The charset of this table
  173. # @param if_exist str : takes values in ['nothing', 'drop']
  174. # @return None
  175. def _create_table(self, table_name, pk_name, pk_ftype, engine, charset = 'utf8', if_exists = 'nothing'):
  176. #Escaped table name
  177. etname = self._idname_escape(table_name)
  178. pk_type = self._field_to_type(pk_ftype)
  179. pk_specs = self._field_to_specs(pk_ftype)
  180. if if_exists == 'drop':
  181. self._query("""DROP TABLE IF EXISTS {table_name};""".format(table_name = etname))
  182. qres = """
  183. CREATE TABLE {table_name} (
  184. {pk_name} {pk_type} {pk_specs},
  185. PRIMARY KEY({pk_name})
  186. ) ENGINE={engine} DEFAULT CHARSET={charset};"""
  187. elif if_exists == 'nothing':
  188. qres = """CREATE TABLE IF NOT EXISTS {table_name} (
  189. {pk_name} {pk_type} {pk_specs},
  190. PRIMARY KEY({pk_name})
  191. ) ENGINE={engine} DEFAULT CHARSET={charset};"""
  192. else:
  193. raise ValueError("Unexpected value for argument if_exists '%s'."%if_exists)
  194. self._query(qres.format(
  195. table_name = self._idname_escape(table_name),
  196. pk_name = self._idname_escape(pk_name),
  197. pk_type = pk_type,
  198. pk_specs = pk_specs,
  199. engine = engine,
  200. charset = charset
  201. ))
  202. ## @brief Add a column to a table
  203. # @param table_name str : The table name
  204. # @param col_name str : The columns name
  205. # @param col_fieldtype EmFieldype the fieldtype
  206. # @return None
  207. def _add_column(self, table_name, col_name, col_fieldtype, drop_if_exists = False):
  208. add_col = """ALTER TABLE {table_name}
  209. ADD COLUMN {col_name} {col_type} {col_specs};"""
  210. etname = self._idname_escape(table_name)
  211. ecname = self._idname_escape(col_name)
  212. add_col = add_col.format(
  213. table_name = etname,
  214. col_name = ecname,
  215. col_type = self._field_to_type(col_fieldtype),
  216. col_specs = self._field_to_specs(col_fieldtype),
  217. )
  218. try:
  219. self._query(add_col)
  220. except _mysql_exceptions.OperationalError as e:
  221. if drop_if_exists:
  222. self._del_column(table_name, col_name)
  223. self._add_column(table_name, col_name, col_fieldtype, drop_if_exists)
  224. else:
  225. #LOG
  226. print("Aborded, column `%s` exists"%col_name)
  227. ## @brief Add a foreign key
  228. # @param src_table_name str : The name of the table where we will add the FK
  229. # @param dst_table_name str : The name of the table the FK will point on
  230. # @param src_col_name str : The name of the concerned column in the src_table
  231. # @param dst_col_name str : The name of the concerned column in the dst_table
  232. def _add_fk(self, src_table_name, dst_table_name, src_col_name, dst_col_name):
  233. stname = self._idname_escape(src_table_name)
  234. dtname = self._idname_escape(dst_table_name)
  235. scname = self._idname_escape(src_col_name)
  236. dcname = self._idname_escape(dst_col_name)
  237. fk_name = self._fk_name(src_table_name, dst_table_name)
  238. self._del_fk(src_table_name, dst_table_name)
  239. self._query("""ALTER TABLE {src_table}
  240. ADD CONSTRAINT {fk_name}
  241. FOREIGN KEY ({src_col}) references {dst_table}({dst_col});""".format(
  242. fk_name = self._idname_escape(fk_name),
  243. src_table = stname,
  244. src_col = scname,
  245. dst_table = dtname,
  246. dst_col = dcname
  247. ))
  248. ## @brief Given a source and a destination table, delete the corresponding FK
  249. # @param src_table_name str : The name of the table where the FK is
  250. # @param dst_table_name str : The name of the table the FK point on
  251. # @warning fails silently
  252. def _del_fk(self, src_table_name, dst_table_name):
  253. try:
  254. self._query("""ALTER TABLE {src_table}
  255. DROP FOREIGN KEY {fk_name}""".format(
  256. src_table = self._idname_escape(src_table_name),
  257. fk_name = self._idname_escape(self._fk_name(src_table_name, dst_table_name))
  258. ))
  259. except _mysql_exceptions.OperationalError: pass
  260. def _fk_name(self, src_table_name, dst_table_name):
  261. return "fk_%s_%s"%(src_table_name, dst_table_name)
  262. ## @brief Generate triggers given a table_name and its columns fieldtypes
  263. # @param table_name str : Table name
  264. # @param cols_ftype dict : with col name as key and column fieldtype as value
  265. # @return None
  266. def _generate_triggers(self, table_name, cols_ftype):
  267. colval_l_upd = dict() #param for update trigger
  268. colval_l_ins = dict() #param for insert trigger
  269. for cname, cftype in cols_ftype.items():
  270. if cftype.ftype == 'datetime':
  271. if cftype.now_on_update:
  272. colval_l_upd[cname] = 'NOW()'
  273. if cftype.now_on_create:
  274. colval_l_ins[cname] = 'NOW()'
  275. self._table_trigger(table_name, 'UPDATE', colval_l_upd)
  276. self._table_trigger(table_name, 'INSERT', colval_l_ins)
  277. ## @brief Create trigger for a table
  278. #
  279. # Primarly designed to create trigger for DATETIME types
  280. # The method generates triggers of the form
  281. #
  282. # CREATE TRIGGER BEFORE <moment> ON <table_name>
  283. # FOR EACH ROW SET <for colname, colval in cols_val>
  284. # NEW.<colname> = <colval>,
  285. # <endfor>;
  286. # @param table_name str : The table name
  287. # @param moment str : can be 'update' or 'insert'
  288. # @param cols_val dict : Dict with column name as key and column value as value
  289. # @return None
  290. def _table_trigger(self, table_name, moment, cols_val):
  291. trigger_name = self._idname_escape("%s_%s_trig"%(table_name, moment))
  292. #Try to delete the trigger
  293. drop_trig = """DROP TRIGGER IF EXISTS {trigger_name};""".format(trigger_name = trigger_name)
  294. self._query(drop_trig)
  295. col_val_l = ', '.join([ "NEW.%s = %s"%(self._idname_escape(cname), cval)for cname, cval in cols_val.items() ])
  296. #Create a trigger if needed
  297. if len(col_val_l) > 0:
  298. trig_q = """CREATE TRIGGER {trigger_name} BEFORE {moment} ON {table_name}
  299. FOR EACH ROW SET {col_val_list};""".format(
  300. trigger_name = trigger_name,
  301. table_name = self._idname_escape(table_name),
  302. moment = moment,
  303. col_val_list = col_val_l
  304. )
  305. self._query(trig_q)
  306. ## @brief Identifier escaping
  307. def _idname_escape(self, idname):
  308. if '`' in idname:
  309. raise ValueError("Invalid name : '%s'"%idname)
  310. return '`%s`'%idname
  311. ## @brief Forge a create table query
  312. # @param table_name str : The name of the table we want to create
  313. # @param colspecs list : List of tuple (fieldname, EmFieldTypes)
  314. # @return A MySQL create table query
  315. #def _create_table_query(self, table_name, colspecs):
  316. # pass
  317. ## @brief Forge a drop column query
  318. # @param table_name str : The name of the concerned table
  319. # @param colname str : The name of the column we want to drop
  320. # @return A MySQL drop column query
  321. def _drop_column_query(self, table_name, colname):
  322. pass
  323. ## @brief Forge an add column query
  324. # @param table_name str : The name of the concerned table
  325. # @param colspec tuple : A tuple (colname, EmFieldType)
  326. # @return A MySQL add column query
  327. def _add_colum_query(self, table_name, colspec):
  328. pass
  329. ## @brief Returns column specs from fieldtype
  330. # @param emfieldtype EmFieldType : An EmFieldType insance
  331. # @todo escape default value
  332. def _field_to_specs(self, emfieldtype):
  333. colspec = ''
  334. if not emfieldtype.nullable:
  335. colspec = 'NOT NULL'
  336. if hasattr(emfieldtype, 'default'):
  337. colspec += ' DEFAULT '
  338. if emfieldtype.default is None:
  339. colspec += 'NULL '
  340. else:
  341. colspec += emfieldtype.default #ESCAPE VALUE HERE !!!!
  342. if emfieldtype.name == 'pk':
  343. colspec += ' AUTO_INCREMENT'
  344. return colspec
  345. ## @brief Given a fieldtype return a MySQL type specifier
  346. # @param emfieldtype EmFieldType : A fieldtype
  347. # @return the corresponding MySQL type
  348. def _field_to_type(self, emfieldtype):
  349. ftype = emfieldtype.ftype
  350. if ftype == 'char' or ftype == 'str':
  351. res = "VARCHAR(%d)"%emfieldtype.max_length
  352. elif ftype == 'text':
  353. res = "TEXT"
  354. elif ftype == 'datetime':
  355. res = "DATETIME"
  356. # client side workaround for only one column with CURRENT_TIMESTAMP : giving NULL to timestamp that don't allows NULL
  357. # cf. https://dev.mysql.com/doc/refman/5.0/en/timestamp-initialization.html#idm139961275230400
  358. # The solution for the migration handler is to create triggers :
  359. # CREATE TRIGGER trigger_name BEFORE INSERT ON `my_super_table`
  360. # FOR EACH ROW SET NEW.my_date_column = NOW();
  361. # and
  362. # CREATE TRIGGER trigger_name BEFORE UPDATE ON
  363. elif ftype == 'bool':
  364. res = "BOOL"
  365. elif ftype == 'int':
  366. res = "INT"
  367. elif ftype == 'rel2type':
  368. res = "INT"
  369. else:
  370. raise ValueError("Unsuported fieldtype ftype : %s"%ftype)
  371. return res
  372. ## @brief Returns a tuple (pkname, pk_ftype)
  373. @property
  374. def _common_field_pk(self):
  375. for fname, fta in EditorialModel.classtypes.common_fields.items():
  376. if fta['fieldtype'] == 'pk':
  377. return (fname, self._common_field_to_ftype(fname))
  378. return (None, None)
  379. ## @brief Returns a tuple (rel_pkname, rel_ftype)
  380. # @todo do it
  381. @property
  382. def _relation_pk(self):
  383. return ('id_relation', EditorialModel.fieldtypes.pk.EmFieldType())
  384. @property
  385. def _relation_cols(self):
  386. from_name = EditorialModel.fieldtypes.generic.GenericFieldType.from_name
  387. return {
  388. 'id_sup': from_name('integer')(),
  389. 'id_sub': from_name('integer')(),
  390. 'rank': from_name('integer')(),
  391. 'depth': from_name('integer')(),
  392. 'nature': from_name('char')(max_lenght=10),
  393. }
  394. ## @brief Given a common field name return an EmFieldType instance
  395. # @param cname str : Common field name
  396. # @return An EmFieldType instance
  397. def _common_field_to_ftype(self, cname):
  398. fta = copy.copy(EditorialModel.classtypes.common_fields[cname])
  399. fto = EditorialModel.fieldtypes.generic.GenericFieldType.from_name(fta['fieldtype'])
  400. del(fta['fieldtype'])
  401. return fto(**fta)