dlg_versioning.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. """
  2. /***************************************************************************
  3. Name : Versioning plugin for DB Manager
  4. Description : Set up versioning support for a table
  5. Date : Mar 12, 2012
  6. copyright : (C) 2012 by Giuseppe Sucameli
  7. email : brush.tyler@gmail.com
  8. Based on PG_Manager by Martin Dobias <wonder.sk@gmail.com> (GPLv2 license)
  9. ***************************************************************************/
  10. /***************************************************************************
  11. * *
  12. * This program is free software; you can redistribute it and/or modify *
  13. * it under the terms of the GNU General Public License as published by *
  14. * the Free Software Foundation; either version 2 of the License, or *
  15. * (at your option) any later version. *
  16. * *
  17. ***************************************************************************/
  18. """
  19. from qgis.PyQt.QtCore import Qt
  20. from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QMessageBox, QApplication
  21. from .ui_DlgVersioning import Ui_DlgVersioning
  22. from .....dlg_db_error import DlgDbError
  23. from ....plugin import BaseError, Table
  24. class DlgVersioning(QDialog, Ui_DlgVersioning):
  25. def __init__(self, item, parent=None):
  26. QDialog.__init__(self, parent)
  27. self.item = item
  28. self.setupUi(self)
  29. self.db = self.item.database()
  30. self.schemas = self.db.schemas()
  31. self.hasSchemas = self.schemas is not None
  32. self.buttonBox.accepted.connect(self.onOK)
  33. self.buttonBox.helpRequested.connect(self.showHelp)
  34. self.populateSchemas()
  35. self.populateTables()
  36. if isinstance(item, Table):
  37. index = self.cboTable.findText(self.item.name)
  38. if index >= 0:
  39. self.cboTable.setCurrentIndex(index)
  40. self.cboSchema.currentIndexChanged.connect(self.populateTables)
  41. # updates of SQL window
  42. self.cboSchema.currentIndexChanged.connect(self.updateSql)
  43. self.cboTable.currentIndexChanged.connect(self.updateSql)
  44. self.chkCreateCurrent.stateChanged.connect(self.updateSql)
  45. self.editPkey.textChanged.connect(self.updateSql)
  46. self.editStart.textChanged.connect(self.updateSql)
  47. self.editEnd.textChanged.connect(self.updateSql)
  48. self.editUser.textChanged.connect(self.updateSql)
  49. self.updateSql()
  50. def populateSchemas(self):
  51. self.cboSchema.clear()
  52. if not self.hasSchemas:
  53. self.hideSchemas()
  54. return
  55. index = -1
  56. for schema in self.schemas:
  57. self.cboSchema.addItem(schema.name)
  58. if hasattr(self.item, 'schema') and schema.name == self.item.schema().name:
  59. index = self.cboSchema.count() - 1
  60. self.cboSchema.setCurrentIndex(index)
  61. def hideSchemas(self):
  62. self.cboSchema.setEnabled(False)
  63. def populateTables(self):
  64. self.tables = []
  65. schemas = self.db.schemas()
  66. if schemas is not None:
  67. schema_name = self.cboSchema.currentText()
  68. matching_schemas = [x for x in schemas if x.name == schema_name]
  69. tables = matching_schemas[0].tables() if len(matching_schemas) > 0 else []
  70. else:
  71. tables = self.db.tables()
  72. self.cboTable.clear()
  73. for table in tables:
  74. if table.type == table.VectorType: # contains geometry column?
  75. self.tables.append(table)
  76. self.cboTable.addItem(table.name)
  77. def get_escaped_name(self, schema, table, suffix):
  78. name = self.db.connector.quoteId("%s%s" % (table, suffix))
  79. schema_name = self.db.connector.quoteId(schema) if schema else None
  80. return "%s.%s" % (schema_name, name) if schema_name else name
  81. def updateSql(self):
  82. if self.cboTable.currentIndex() < 0 or len(self.tables) < self.cboTable.currentIndex():
  83. return
  84. self.table = self.tables[self.cboTable.currentIndex()]
  85. self.schematable = self.table.quotedName()
  86. self.current = self.chkCreateCurrent.isChecked()
  87. self.colPkey = self.db.connector.quoteId(self.editPkey.text())
  88. self.colStart = self.db.connector.quoteId(self.editStart.text())
  89. self.colEnd = self.db.connector.quoteId(self.editEnd.text())
  90. self.colUser = self.db.connector.quoteId(self.editUser.text())
  91. self.columns = [self.db.connector.quoteId(x.name) for x in self.table.fields()]
  92. self.colOrigPkey = None
  93. for constr in self.table.constraints():
  94. if constr.type == constr.TypePrimaryKey:
  95. self.origPkeyName = self.db.connector.quoteId(constr.name)
  96. self.colOrigPkey = [self.db.connector.quoteId(x_y[1].name) for x_y in iter(list(constr.fields().items()))]
  97. break
  98. if self.colOrigPkey is None:
  99. self.txtSql.setPlainText("Table doesn't have a primary key!")
  100. self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
  101. return
  102. elif len(self.colOrigPkey) > 1:
  103. self.txtSql.setPlainText("Table has multicolumn primary key!")
  104. self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
  105. return
  106. # take first (and only column of the pkey)
  107. self.colOrigPkey = self.colOrigPkey[0]
  108. # define view, function, rule and trigger names
  109. self.view = self.get_escaped_name(self.table.schemaName(), self.table.name, "_current")
  110. self.func_at_time = self.get_escaped_name(self.table.schemaName(), self.table.name, "_at_time")
  111. self.func_update = self.get_escaped_name(self.table.schemaName(), self.table.name, "_update")
  112. self.func_insert = self.get_escaped_name(self.table.schemaName(), self.table.name, "_insert")
  113. self.rule_del = self.get_escaped_name(None, self.table.name, "_del")
  114. self.trigger_update = self.get_escaped_name(None, self.table.name, "_update")
  115. self.trigger_insert = self.get_escaped_name(None, self.table.name, "_insert")
  116. sql = []
  117. # modify table: add serial column, start time, end time
  118. sql.append(self.sql_alterTable())
  119. # add primary key to the table
  120. sql.append(self.sql_setPkey())
  121. sql.append(self.sql_currentView())
  122. # add X_at_time, X_update, X_delete functions
  123. sql.append(self.sql_functions())
  124. # add insert, update trigger, delete rule
  125. sql.append(self.sql_triggers())
  126. # add _current view + updatable
  127. # if self.current:
  128. sql.append(self.sql_updatesView())
  129. self.txtSql.setPlainText('\n\n'.join(sql))
  130. self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)
  131. return sql
  132. def showHelp(self):
  133. helpText = """In this dialog you can set up versioning support for a table. The table will be modified so that all changes will be recorded: there will be a column with start time and end time. Every row will have its start time, end time is assigned when the feature gets deleted. When a row is modified, the original data is marked with end time and new row is created. With this system, it's possible to get back to state of the table any time in history. When selecting rows from the table, you will always have to specify at what time do you want the rows."""
  134. QMessageBox.information(self, "Help", helpText)
  135. def sql_alterTable(self):
  136. return "ALTER TABLE %s ADD %s serial, ADD %s timestamp default '-infinity', ADD %s timestamp, ADD %s varchar;" % (
  137. self.schematable, self.colPkey, self.colStart, self.colEnd, self.colUser)
  138. def sql_setPkey(self):
  139. return "ALTER TABLE %s DROP CONSTRAINT %s, ADD PRIMARY KEY (%s);" % (
  140. self.schematable, self.origPkeyName, self.colPkey)
  141. def sql_currentView(self):
  142. cols = self.colPkey + "," + ",".join(self.columns)
  143. return "CREATE VIEW %(view)s AS SELECT %(cols)s FROM %(schematable)s WHERE %(end)s IS NULL;" % \
  144. {'view': self.view, 'cols': cols, 'schematable': self.schematable, 'end': self.colEnd}
  145. def sql_functions(self):
  146. cols = ",".join(self.columns)
  147. all_cols = self.colPkey + "," + ",".join(self.columns)
  148. old_cols = ",".join("OLD." + x for x in self.columns)
  149. sql = """
  150. CREATE OR REPLACE FUNCTION %(func_at_time)s(timestamp)
  151. RETURNS SETOF %(view)s AS
  152. $$
  153. SELECT %(all_cols)s FROM %(schematable)s WHERE
  154. ( SELECT CASE WHEN %(end)s IS NULL THEN (%(start)s <= $1) ELSE (%(start)s <= $1 AND %(end)s > $1) END );
  155. $$
  156. LANGUAGE 'sql';
  157. CREATE OR REPLACE FUNCTION %(func_update)s()
  158. RETURNS TRIGGER AS
  159. $$
  160. BEGIN
  161. IF OLD.%(end)s IS NOT NULL THEN
  162. RETURN NULL;
  163. END IF;
  164. IF NEW.%(end)s IS NULL THEN
  165. INSERT INTO %(schematable)s (%(cols)s, %(start)s, %(end)s) VALUES (%(oldcols)s, OLD.%(start)s, current_timestamp);
  166. NEW.%(start)s = current_timestamp;
  167. NEW.%(user)s = current_user;
  168. END IF;
  169. RETURN NEW;
  170. END;
  171. $$
  172. LANGUAGE 'plpgsql';
  173. CREATE OR REPLACE FUNCTION %(func_insert)s()
  174. RETURNS trigger AS
  175. $$
  176. BEGIN
  177. if NEW.%(start)s IS NULL then
  178. NEW.%(start)s = now();
  179. NEW.%(end)s = null;
  180. NEW.%(user)s = current_user;
  181. end if;
  182. RETURN NEW;
  183. END;
  184. $$
  185. LANGUAGE 'plpgsql';""" % {'view': self.view, 'schematable': self.schematable, 'cols': cols, 'oldcols': old_cols,
  186. 'start': self.colStart, 'end': self.colEnd, 'user': self.colUser, 'func_at_time': self.func_at_time,
  187. 'all_cols': all_cols, 'func_update': self.func_update, 'func_insert': self.func_insert}
  188. return sql
  189. def sql_triggers(self):
  190. return """
  191. CREATE RULE %(rule_del)s AS ON DELETE TO %(schematable)s
  192. DO INSTEAD UPDATE %(schematable)s SET %(end)s = current_timestamp WHERE %(pkey)s = OLD.%(pkey)s AND %(end)s IS NULL;
  193. CREATE TRIGGER %(trigger_update)s BEFORE UPDATE ON %(schematable)s
  194. FOR EACH ROW EXECUTE PROCEDURE %(func_update)s();
  195. CREATE TRIGGER %(trigger_insert)s BEFORE INSERT ON %(schematable)s
  196. FOR EACH ROW EXECUTE PROCEDURE %(func_insert)s();""" % \
  197. {'rule_del': self.rule_del, 'trigger_update': self.trigger_update, 'trigger_insert': self.trigger_insert,
  198. 'func_update': self.func_update, 'func_insert': self.func_insert, 'schematable': self.schematable,
  199. 'pkey': self.colPkey, 'end': self.colEnd}
  200. def sql_updatesView(self):
  201. cols = ",".join(self.columns)
  202. return_cols = self.colPkey + "," + ",".join(self.columns)
  203. new_cols = ",".join("NEW." + x for x in self.columns)
  204. assign_cols = ",".join("%s = NEW.%s" % (x, x) for x in self.columns)
  205. return """
  206. CREATE OR REPLACE RULE "_DELETE" AS ON DELETE TO %(view)s DO INSTEAD
  207. DELETE FROM %(schematable)s WHERE %(origpkey)s = old.%(origpkey)s;
  208. CREATE OR REPLACE RULE "_INSERT" AS ON INSERT TO %(view)s DO INSTEAD
  209. INSERT INTO %(schematable)s (%(cols)s) VALUES (%(newcols)s) RETURNING %(return_cols)s;
  210. CREATE OR REPLACE RULE "_UPDATE" AS ON UPDATE TO %(view)s DO INSTEAD
  211. UPDATE %(schematable)s SET %(assign)s WHERE %(origpkey)s = NEW.%(origpkey)s;""" % {'view': self.view,
  212. 'schematable': self.schematable,
  213. 'cols': cols, 'newcols': new_cols,
  214. 'return_cols': return_cols,
  215. 'assign': assign_cols,
  216. 'origpkey': self.colOrigPkey}
  217. def onOK(self):
  218. # execute and commit the code
  219. QApplication.setOverrideCursor(Qt.WaitCursor)
  220. try:
  221. sql = "\n".join(self.updateSql())
  222. self.db.connector._execute_and_commit(sql)
  223. except BaseError as e:
  224. DlgDbError.showError(e, self)
  225. return
  226. finally:
  227. QApplication.restoreOverrideCursor()
  228. QMessageBox.information(self, "DB Manager", "Versioning was successfully created.")
  229. self.accept()