2 # Copyright (c) 2003 Martynas Sklyzmantas, Andrey Lebedev <andrey@micro.lt>
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
9 '''This module defines a backend implementation for MySQL.
12 How to implement AUTO_INCREMENT:
14 mysql> create table foo (num integer auto_increment primary key, name
15 varchar(255)) AUTO_INCREMENT=1 ENGINE=InnoDB;
17 ql> insert into foo (name) values ('foo5');
18 Query OK, 1 row affected (0.00 sec)
20 mysql> SELECT num FROM foo WHERE num IS NULL;
26 1 row in set (0.00 sec)
28 mysql> SELECT num FROM foo WHERE num IS NULL;
31 NOTE: we don't need an index on the id column if it's PRIMARY KEY
34 __docformat__
= 'restructuredtext'
36 from roundup
.backends
.rdbms_common
import *
37 from roundup
.backends
import rdbms_common
40 from MySQLdb
.constants
import ER
43 def connection_dict(config
, dbnamestr
=None):
44 d
= rdbms_common
.connection_dict(config
, dbnamestr
)
45 if d
.has_key('password'):
46 d
['passwd'] = d
['password']
49 d
['port'] = int(d
['port'])
53 """Clear all database contents and drop database itself"""
55 kwargs
= connection_dict(config
)
56 conn
= MySQLdb
.connect(**kwargs
)
58 conn
.select_db(config
.RDBMS_NAME
)
60 # no, it doesn't exist
63 cursor
= conn
.cursor()
64 cursor
.execute("SHOW TABLES")
65 tables
= cursor
.fetchall()
66 # stupid MySQL bug requires us to drop all the tables first
68 command
= 'DROP TABLE `%s`'%table
[0]
69 logging
.debug(command
)
70 cursor
.execute(command
)
71 command
= "DROP DATABASE %s"%config
.RDBMS_NAME
73 cursor
.execute(command
)
77 if os
.path
.exists(config
.DATABASE
):
78 shutil
.rmtree(config
.DATABASE
)
80 def db_create(config
):
81 """Create the database."""
82 kwargs
= connection_dict(config
)
83 conn
= MySQLdb
.connect(**kwargs
)
84 cursor
= conn
.cursor()
85 command
= "CREATE DATABASE %s"%config
.RDBMS_NAME
87 cursor
.execute(command
)
91 def db_exists(config
):
92 """Check if database already exists."""
93 kwargs
= connection_dict(config
)
94 conn
= MySQLdb
.connect(**kwargs
)
97 conn
.select_db(config
.RDBMS_NAME
)
98 except MySQLdb
.OperationalError
:
105 class Database(Database
):
108 # used by some code to switch styles of query
109 implements_intersect
= 0
111 # Backend for MySQL to use.
112 # InnoDB is faster, but if you're running <4.0.16 then you'll need to
113 # use BDB to pass all unit tests.
114 mysql_backend
= 'InnoDB'
115 #mysql_backend = 'BDB'
117 hyperdb_to_sql_datatypes
= {
118 hyperdb
.String
: 'TEXT',
119 hyperdb
.Date
: 'DATETIME',
120 hyperdb
.Link
: 'INTEGER',
121 hyperdb
.Interval
: 'VARCHAR(255)',
122 hyperdb
.Password
: 'VARCHAR(255)',
123 hyperdb
.Boolean
: 'BOOL',
124 hyperdb
.Number
: 'REAL',
127 hyperdb_to_sql_value
= {
128 hyperdb
.String
: str,
129 # no fractional seconds for MySQL
130 hyperdb
.Date
: lambda x
: x
.formal(sep
=' '),
132 hyperdb
.Interval
: str,
133 hyperdb
.Password
: str,
134 hyperdb
.Boolean
: int,
135 hyperdb
.Number
: lambda x
: x
,
136 hyperdb
.Multilink
: lambda x
: x
, # used in journal marshalling
139 def sql_open_connection(self
):
140 kwargs
= connection_dict(self
.config
, 'db')
141 self
.log_info('open database %r'%(kwargs
['db'],))
143 conn
= MySQLdb
.connect(**kwargs
)
144 except MySQLdb
.OperationalError
, message
:
145 raise DatabaseError
, message
146 cursor
= conn
.cursor()
147 cursor
.execute("SET AUTOCOMMIT=0")
148 cursor
.execute("START TRANSACTION")
149 return (conn
, cursor
)
151 def open_connection(self
):
152 # make sure the database actually exists
153 if not db_exists(self
.config
):
154 db_create(self
.config
)
156 self
.conn
, self
.cursor
= self
.sql_open_connection()
160 except MySQLdb
.OperationalError
, message
:
161 if message
[0] != ER
.NO_DB_ERROR
:
163 except MySQLdb
.ProgrammingError
, message
:
164 if message
[0] != ER
.NO_SUCH_TABLE
:
165 raise DatabaseError
, message
167 self
.sql("CREATE TABLE `schema` (`schema` TEXT) ENGINE=%s"%
169 self
.sql('''CREATE TABLE ids (name VARCHAR(255),
170 num INTEGER) ENGINE=%s'''%self
.mysql_backend
)
171 self
.sql('create index ids_name_idx on ids(name)')
172 self
.create_version_2_tables()
174 def load_dbschema(self
):
175 ''' Load the schema definition that the database currently implements
177 self
.cursor
.execute('select `schema` from `schema`')
178 schema
= self
.cursor
.fetchone()
180 self
.database_schema
= eval(schema
[0])
182 self
.database_schema
= {}
184 def save_dbschema(self
):
185 ''' Save the schema definition that the database currently implements
187 s
= repr(self
.database_schema
)
188 self
.sql('delete from `schema`')
189 self
.sql('insert into `schema` values (%s)', (s
,))
191 def create_version_2_tables(self
):
193 self
.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
194 otk_value TEXT, otk_time FLOAT(20))
195 ENGINE=%s'''%self
.mysql_backend
)
196 self
.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
199 self
.sql('''CREATE TABLE sessions (session_key VARCHAR(255),
200 session_time FLOAT(20), session_value TEXT)
201 ENGINE=%s'''%self
.mysql_backend
)
202 self
.sql('''CREATE INDEX sessions_key_idx ON
203 sessions(session_key)''')
205 # full-text indexing store
206 self
.sql('''CREATE TABLE __textids (_class VARCHAR(255),
207 _itemid VARCHAR(255), _prop VARCHAR(255), _textid INT)
208 ENGINE=%s'''%self
.mysql_backend
)
209 self
.sql('''CREATE TABLE __words (_word VARCHAR(30),
210 _textid INT) ENGINE=%s'''%self
.mysql_backend
)
211 self
.sql('CREATE INDEX words_word_ids ON __words(_word)')
212 self
.sql('CREATE INDEX words_by_id ON __words (_textid)')
213 self
.sql('CREATE UNIQUE INDEX __textids_by_props ON '
214 '__textids (_class, _itemid, _prop)')
215 sql
= 'insert into ids (name, num) values (%s,%s)'%(self
.arg
, self
.arg
)
216 self
.sql(sql
, ('__textids', 1))
218 def add_new_columns_v2(self
):
219 '''While we're adding the actor column, we need to update the
220 tables to have the correct datatypes.'''
221 for klass
in self
.classes
.values():
223 properties
= klass
.getprops()
224 old_spec
= self
.database_schema
['tables'][cn
]
226 # figure the non-Multilink properties to copy over
227 propnames
= ['activity', 'creation', 'creator']
229 # figure actions based on data type
230 for name
, s_prop
in old_spec
[1]:
231 # s_prop is a repr() string of a hyperdb type object
232 if s_prop
.find('Multilink') == -1:
233 if properties
.has_key(name
):
234 propnames
.append(name
)
236 tn
= '%s_%s'%(cn
, name
)
238 if properties
.has_key(name
):
239 # grabe the current values
240 sql
= 'select linkid, nodeid from %s'%tn
242 rows
= self
.cursor
.fetchall()
245 self
.drop_multilink_table_indexes(cn
, name
)
246 sql
= 'drop table %s'%tn
249 if properties
.has_key(name
):
250 # re-create and populate the new table
251 self
.create_multilink_table(klass
, name
)
252 sql
= '''insert into %s (linkid, nodeid) values
253 (%s, %s)'''%(tn
, self
.arg
, self
.arg
)
254 for linkid
, nodeid
in rows
:
255 self
.sql(sql
, (int(linkid
), int(nodeid
)))
257 # figure the column names to fetch
258 fetch
= ['_%s'%name
for name
in propnames
]
260 # select the data out of the old table
262 fetch
.append('__retired__')
263 fetchcols
= ','.join(fetch
)
264 sql
= 'select %s from _%s'%(fetchcols
, cn
)
267 # unserialise the old data
269 propnames
= propnames
+ ['id', '__retired__']
272 for entry
in self
.cursor
.fetchall():
275 for i
in range(len(propnames
)):
279 if name
in ('id', '__retired__'):
285 cols
.append('_' + name
)
286 prop
= properties
[name
]
287 if isinstance(prop
, Date
) and v
is not None:
289 elif isinstance(prop
, Interval
) and v
is not None:
291 elif isinstance(prop
, Password
) and v
is not None:
292 v
= password
.Password(encrypted
=v
)
293 elif (isinstance(prop
, Boolean
) or
294 isinstance(prop
, Number
)) and v
is not None:
297 # convert to new MySQL data type
298 prop
= properties
[name
]
300 e
= self
.to_sql_value(prop
.__class__
)(v
)
305 # Intervals store the seconds value too
306 if isinstance(prop
, Interval
):
308 cols
.append('__' + name
+ '_int__')
310 l
.append(v
.as_seconds())
315 self
.drop_class_table_indexes(cn
, old_spec
[0])
318 self
.sql('drop table _%s'%cn
)
320 # create the new table
321 self
.create_class_table(klass
)
323 # do the insert of the old data
324 args
= ','.join([self
.arg
for x
in cols
])
325 cols
= ','.join(cols
)
326 sql
= 'insert into _%s (%s) values (%s)'%(cn
, cols
, args
)
327 for entry
in olddata
:
328 self
.sql(sql
, tuple(entry
))
330 # now load up the old journal data to migrate it
331 cols
= ','.join('nodeid date tag action params'.split())
332 sql
= 'select %s from %s__journal'%(cols
, cn
)
337 for nodeid
, journaldate
, journaltag
, action
, params
in \
338 self
.cursor
.fetchall():
339 #nodeid = int(nodeid)
340 journaldate
= date
.Date(journaldate
)
341 #params = eval(params)
342 olddata
.append((nodeid
, journaldate
, journaltag
, action
,
345 # drop journal table and indexes
346 self
.drop_journal_table_indexes(cn
)
347 sql
= 'drop table %s__journal'%cn
350 # re-create journal table
351 self
.create_journal_table(klass
)
352 dc
= self
.to_sql_value(hyperdb
.Date
)
353 for nodeid
, journaldate
, journaltag
, action
, params
in olddata
:
354 self
.save_journal(cn
, cols
, nodeid
, dc(journaldate
),
355 journaltag
, action
, params
)
357 # make sure the normal schema update code doesn't try to
359 self
.database_schema
['tables'][cn
] = klass
.schema()
361 def fix_version_2_tables(self
):
362 # Convert journal date column to TIMESTAMP, params column to TEXT
363 self
._convert_journal_tables()
365 # Convert all String properties to TEXT
366 self
._convert_string_properties()
369 return '<myroundsql 0x%x>'%id(self
)
371 def sql_fetchone(self
):
372 return self
.cursor
.fetchone()
374 def sql_fetchall(self
):
375 return self
.cursor
.fetchall()
377 def sql_index_exists(self
, table_name
, index_name
):
378 self
.sql('show index from %s'%table_name
)
379 for index
in self
.cursor
.fetchall():
380 if index
[2] == index_name
:
384 def create_class_table(self
, spec
, create_sequence
=1):
385 cols
, mls
= self
.determine_columns(spec
.properties
.items())
387 # add on our special columns
388 cols
.append(('id', 'INTEGER PRIMARY KEY'))
389 cols
.append(('__retired__', 'INTEGER DEFAULT 0'))
391 # create the base table
392 scols
= ','.join(['%s %s'%x
for x
in cols
])
393 sql
= 'create table _%s (%s) ENGINE=%s'%(spec
.classname
, scols
,
397 self
.create_class_table_indexes(spec
)
400 def create_class_table_indexes(self
, spec
):
401 ''' create the class table for the given spec
403 # create __retired__ index
404 index_sql2
= 'create index _%s_retired_idx on _%s(__retired__)'%(
405 spec
.classname
, spec
.classname
)
408 # create index for key property
410 if isinstance(spec
.properties
[spec
.key
], String
):
411 idx
= spec
.key
+ '(255)'
414 index_sql3
= 'create index _%s_%s_idx on _%s(_%s)'%(
415 spec
.classname
, spec
.key
,
419 # TODO: create indexes on (selected?) Link property columns, as
420 # they're more likely to be used for lookup
422 def add_class_key_required_unique_constraint(self
, cn
, key
):
423 # mysql requires sizes on TEXT indexes
424 prop
= self
.classes
[cn
].getprops()[key
]
425 if isinstance(prop
, String
):
426 sql
= '''create unique index _%s_key_retired_idx
427 on _%s(__retired__, _%s(255))'''%(cn
, cn
, key
)
429 sql
= '''create unique index _%s_key_retired_idx
430 on _%s(__retired__, _%s)'''%(cn
, cn
, key
)
433 def create_class_table_key_index(self
, cn
, key
):
434 # mysql requires sizes on TEXT indexes
435 prop
= self
.classes
[cn
].getprops()[key
]
436 if isinstance(prop
, String
):
437 sql
= 'create index _%s_%s_idx on _%s(_%s(255))'%(cn
, key
, cn
, key
)
439 sql
= 'create index _%s_%s_idx on _%s(_%s)'%(cn
, key
, cn
, key
)
442 def drop_class_table_indexes(self
, cn
, key
):
443 # drop the old table indexes first
444 l
= ['_%s_id_idx'%cn
, '_%s_retired_idx'%cn
]
446 l
.append('_%s_%s_idx'%(cn
, key
))
448 table_name
= '_%s'%cn
450 if not self
.sql_index_exists(table_name
, index_name
):
452 index_sql
= 'drop index %s on %s'%(index_name
, table_name
)
455 def create_journal_table(self
, spec
):
456 ''' create the journal table for a class given the spec and
457 already-determined cols
460 cols
= ','.join(['%s varchar'%x
461 for x
in 'nodeid date tag action params'.split()])
462 sql
= '''create table %s__journal (
463 nodeid integer, date datetime, tag varchar(255),
464 action varchar(255), params text) ENGINE=%s'''%(
465 spec
.classname
, self
.mysql_backend
)
467 self
.create_journal_table_indexes(spec
)
469 def drop_journal_table_indexes(self
, classname
):
470 index_name
= '%s_journ_idx'%classname
471 if not self
.sql_index_exists('%s__journal'%classname
, index_name
):
473 index_sql
= 'drop index %s on %s__journal'%(index_name
, classname
)
476 def create_multilink_table(self
, spec
, ml
):
477 sql
= '''CREATE TABLE `%s_%s` (linkid VARCHAR(255),
478 nodeid VARCHAR(255)) ENGINE=%s'''%(spec
.classname
, ml
,
481 self
.create_multilink_table_indexes(spec
, ml
)
483 def drop_multilink_table_indexes(self
, classname
, ml
):
485 '%s_%s_l_idx'%(classname
, ml
),
486 '%s_%s_n_idx'%(classname
, ml
)
488 table_name
= '%s_%s'%(classname
, ml
)
490 if not self
.sql_index_exists(table_name
, index_name
):
492 sql
= 'drop index %s on %s'%(index_name
, table_name
)
495 def drop_class_table_key_index(self
, cn
, key
):
496 table_name
= '_%s'%cn
497 index_name
= '_%s_%s_idx'%(cn
, key
)
498 if not self
.sql_index_exists(table_name
, index_name
):
500 sql
= 'drop index %s on %s'%(index_name
, table_name
)
503 # old-skool id generation
504 def newid(self
, classname
):
505 ''' Generate a new id for the given class
507 # get the next ID - "FOR UPDATE" will lock the row for us
508 sql
= 'select num from ids where name=%s FOR UPDATE'%self
.arg
509 self
.sql(sql
, (classname
, ))
510 newid
= int(self
.cursor
.fetchone()[0])
513 sql
= 'update ids set num=%s where name=%s'%(self
.arg
, self
.arg
)
514 vals
= (int(newid
)+1, classname
)
520 def setid(self
, classname
, setid
):
521 ''' Set the id counter: used during import of database
523 We add one to make it behave like the seqeunces in postgres.
525 sql
= 'update ids set num=%s where name=%s'%(self
.arg
, self
.arg
)
526 vals
= (int(setid
)+1, classname
)
530 rdbms_common
.Database
.clear(self
)
532 # set the id counters to 0 (setid adds one) so we start at 1
533 for cn
in self
.classes
.keys():
536 def create_class(self
, spec
):
537 rdbms_common
.Database
.create_class(self
, spec
)
538 sql
= 'insert into ids (name, num) values (%s, %s)'
539 vals
= (spec
.classname
, 1)
542 def sql_commit(self
, fail_ok
=False):
543 ''' Actually commit to the database.
545 self
.log_info('commit')
547 # MySQL commits don't seem to ever fail, the latest update winning.
548 # makes you wonder why they have transactions...
551 # open a new cursor for subsequent work
552 self
.cursor
= self
.conn
.cursor()
554 # make sure we're in a new transaction and not autocommitting
555 self
.sql("SET AUTOCOMMIT=0")
556 self
.sql("START TRANSACTION")
559 self
.log_info('close')
562 except MySQLdb
.ProgrammingError
, message
:
563 if str(message
) != 'closing a closed connection':
567 def _subselect(self
, classname
, multilink_table
):
568 ''' "I can't believe it's not a toy RDBMS"
569 see, even toy RDBMSes like gadfly and sqlite can do sub-selects...
571 self
.db
.sql('select nodeid from %s'%multilink_table
)
572 s
= ','.join([x
[0] for x
in self
.db
.sql_fetchall()])
573 return '_%s.id not in (%s)'%(classname
, s
)
575 def create_inner(self
, **propvalues
):
577 return rdbms_common
.Class
.create_inner(self
, **propvalues
)
578 except MySQLdb
.IntegrityError
, e
:
579 self
._handle_integrity_error(e
, propvalues
)
581 def set_inner(self
, nodeid
, **propvalues
):
583 return rdbms_common
.Class
.set_inner(self
, nodeid
,
585 except MySQLdb
.IntegrityError
, e
:
586 self
._handle_integrity_error(e
, propvalues
)
588 def _handle_integrity_error(self
, e
, propvalues
):
589 ''' Handle a MySQL IntegrityError.
591 If the error is recognized, then it may be converted into an
592 alternative exception. Otherwise, it is raised unchanged from
595 # There are checks in create_inner/set_inner to see if a node
596 # is being created with the same key as an existing node.
597 # But, there is a race condition -- we may pass those checks,
598 # only to find out that a parallel session has created the
599 # node by by the time we actually issue the SQL command to
600 # create the node. Fortunately, MySQL gives us a unique error
601 # code for this situation, so we can detect it here and handle
604 # The details of the race condition are as follows, where
605 # "X" is a classname, and the term "thread" is meant to
606 # refer generically to both threads and processes:
611 # create new X object
613 # create new X object
615 # In Thread B, the check in create_inner does not notice that
616 # the new X object is a duplicate of that committed in Thread
617 # A because MySQL's default "consistent nonlocking read"
618 # behavior means that Thread B sees a snapshot of the database
619 # at the point at which its transaction began -- which was
620 # before Thread A created the object. However, the attempt
621 # to *write* to the table for X, creating a duplicate entry,
622 # triggers an error at the point of the write.
624 # If both A and B's transaction begins with creating a new X
625 # object, then this bug cannot occur because creating the
626 # object requires getting a new ID, and newid() locks the id
627 # table until the transaction is committed or rolledback. So,
628 # B will block until A's commit is complete, and will not
629 # actually get its snapshot until A's transaction completes.
630 # But, if the transaction has begun prior to calling newid,
631 # then the snapshot has already been established.
632 if e
[0] == ER
.DUP_ENTRY
:
633 key
= propvalues
[self
.key
]
634 raise ValueError, 'node with key "%s" exists' % key
635 # We don't know what this exception is; reraise it.
639 class Class(MysqlClass
, rdbms_common
.Class
):
641 class IssueClass(MysqlClass
, rdbms_common
.IssueClass
):
643 class FileClass(MysqlClass
, rdbms_common
.FileClass
):
646 # vim: set et sts=4 sw=4 :