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.
 
 
 
 

732 line
22 KiB

  1. # -*- coding: iso-8859-1 -*-
  2. # pylint: disable-msg=W0302
  3. """
  4. An Anti-ORM, a simple utility functions to ease the writing of SQL statements
  5. with Python DBAPI-2.0 bindings. This is not an ORM, but it's just as tasty!
  6. This is a only set of support classes that make it easier to write your own
  7. queries yet have some automation with the annoying tasks of setting up lists of
  8. column names and values, as well as doing the type conversions automatically.
  9. And most importantly...
  10. THERE IS NO FRIGGIN' MAGIC IN HERE.
  11. Some notes:
  12. * You always have to pass in the connection objects that the operations are
  13. performed on. This allows connection pooling to be entirely separate from
  14. this library.
  15. * There is never any automatic commit performed here, you must commit your
  16. connection by yourself after you've executed the appropriate commands.
  17. Usage
  18. =====
  19. Most of the convenience functions accept a WHERE condition and a tuple or list
  20. of arguments, which are simply passed on to the DBAPI interface.
  21. Declaring Tables
  22. ----------------
  23. The table must declare the SQL table's name on the 'table' class attribute and
  24. should derive from MormTable.
  25. You do not need to declare columns on your tables. However, if you need custom
  26. conversions--right now, only string vs. unicode are useful--you declare a
  27. 'converters' mapping from SQL column name to the converter to be used, just for
  28. the columns which require conversion (you can leave others alone). You can
  29. create your own custom converters if so desired.
  30. The class of objects that are returned by the query methods can be defaulted by
  31. setting 'objcls' on the table. This class should/may derive from MormObject,
  32. e.g.::
  33. class TestTable(MormTable):
  34. table = 'test1'
  35. objcls = Person
  36. converters = {
  37. 'firstname': MormConvUnicode(),
  38. 'lastname': MormConvUnicode(),
  39. 'religion': MormConvString()
  40. }
  41. Insert (C)
  42. ----------
  43. Insert some new row in a table::
  44. TestTable.insert(connection,
  45. firstname=u'Adriana',
  46. lastname=u'Sousa',
  47. religion='candomblé')
  48. Select (R)
  49. ----------
  50. Add a where condition, and select some columns::
  51. for obj in TestTable.select(connection,
  52. 'WHERE id = %s', (2,), cols=('id', 'username')):
  53. # Access obj.id, obj.username
  54. The simplest version is simply accessing everything::
  55. for obj in TestTable.select(connection):
  56. # Access obj.id, obj.username and more.
  57. Update (U)
  58. ----------
  59. Update statements are provided as well::
  60. TestTable.update(connection,
  61. 'WHERE id = %s', (2,),
  62. lastname=u'Depardieu',
  63. religion='candomblé')
  64. Delete (D)
  65. ----------
  66. Deleting rows can be done similarly::
  67. TestTable.delete('WHERE id = %s', (1,))
  68. Lower-Level APIs
  69. ----------------
  70. See the tests at the of this file for examples on how to do things at a
  71. lower-level, which is necessary for complex queries (not that it hurts too much
  72. either). In particular, you should have a look at the MormDecoder and
  73. MormEncoder classes.
  74. See doc/ in distribution for additional notes.
  75. """
  76. __author__ = 'Martin Blais <blais@furius.ca>'
  77. __all__ = ['MormTable', 'MormObject', 'MormError',
  78. 'MormConv', 'MormConvUnicode', 'MormConvString',
  79. 'MormDecoder', 'MormEncoder']
  80. class NODEF(object):
  81. """
  82. No-defaults constant.
  83. """
  84. class MormObject(object):
  85. """
  86. An instance of an initialized decoded row.
  87. This is just a dummy container for attributes.
  88. """
  89. class MormTable(object):
  90. """
  91. Class for declarations that relate to a table.
  92. This acts as the base class on which derived classes add custom conversions.
  93. An instance of this class acts as a wrapper decoder and iterator object,
  94. whose behaviour depends on the custom converters.
  95. """
  96. #---------------------------------------------------------------------------
  97. table = None
  98. "Table name in the database."
  99. pkseq = None
  100. "Sequence for primary key."
  101. objcls = MormObject
  102. "Class of objects to create"
  103. converters = {}
  104. "Custom converter map for columns"
  105. #---------------------------------------------------------------------------
  106. # Misc methods.
  107. @classmethod
  108. def tname(cls):
  109. assert cls.table is not None
  110. return cls.table
  111. @classmethod
  112. def encoder(cls, **cols):
  113. """
  114. Encode the given columns according to this class' definition.
  115. """
  116. return MormEncoder(cls, cols)
  117. @classmethod
  118. def decoder(cls, desc):
  119. """
  120. Create a decoder for the column names described by 'desc'. 'desc' can
  121. be either a sequence of column names, or a cursor from which we will
  122. fetch the description. You will still have to pass in the cursor for
  123. decoding later on.
  124. """
  125. return MormDecoder(cls, desc)
  126. #---------------------------------------------------------------------------
  127. # Methods that only read from the connection
  128. @classmethod
  129. def count(cls, conn, cond=None, args=None, distinct=None):
  130. """
  131. Counts the number of selected rows.
  132. """
  133. assert conn is not None
  134. # Perform the select.
  135. cursor = MormDecoder.do_select(conn, (cls,), ('1',),
  136. cond, args, distinct)
  137. # Return the number of matches.
  138. return cursor.rowcount
  139. @classmethod
  140. def select(cls, conn, cond=None, args=None, cols=None,
  141. objcls=None, distinct=None):
  142. """
  143. Convenience method that executes a select and returns an iterator for
  144. the results, wrapped in objects with attributes
  145. """
  146. assert conn is not None
  147. # Perform the select.
  148. cursor = MormDecoder.do_select(conn, (cls,), cols,
  149. cond, args, distinct)
  150. # Create a decoder using the description on the cursor.
  151. dec = MormDecoder(cls, cursor)
  152. # Return an iterator over the cursor.
  153. return dec.iter(cursor, objcls)
  154. @classmethod
  155. def select_all(cls, conn, cond=None, args=None, cols=None,
  156. objcls=None, distinct=None):
  157. """
  158. Convenience method that executes a select and returns a list of all the
  159. results, wrapped in objects with attributes
  160. """
  161. assert conn is not None
  162. # Perform the select.
  163. cursor = MormDecoder.do_select(conn, (cls,), cols,
  164. cond, args, distinct)
  165. # Create a decoder using the description on the cursor.
  166. dec = MormDecoder(cls, cursor)
  167. # Fetch all the objects from the cursor and decode them.
  168. objects = []
  169. for row in cursor.fetchall():
  170. objects.append(dec.decode(row, objcls=objcls))
  171. return objects
  172. @classmethod
  173. def select_one(cls, conn, cond=None, args=None, cols=None,
  174. objcls=None, distinct=None):
  175. """
  176. Convenience method that executes a select the first object that matches,
  177. and that also checks that there is a single object that matches.
  178. """
  179. it = cls.select(conn, cond, args, cols, objcls, distinct)
  180. if len(it) > 1:
  181. raise MormError("select_one() matches more than one row.")
  182. try:
  183. o = it.next()
  184. except StopIteration:
  185. o = None
  186. return o
  187. @classmethod
  188. def get(cls, conn, cols=None, default=NODEF, **constraints):
  189. """
  190. Convenience method that gets a single object by its primary key.
  191. """
  192. cons, args = [], []
  193. for colname, colvalue in list(constraints.items()):
  194. cons.append('%s = %%s' % colname)
  195. args.append(colvalue)
  196. cond = 'WHERE ' + ' AND '.join(cons)
  197. it = cls.select(conn, cond, args, cols)
  198. try:
  199. if len(it) == 0:
  200. if default is NODEF:
  201. raise MormError("Object not found (%s)." % str(constraints))
  202. else:
  203. return default
  204. return it.next()
  205. finally:
  206. del it
  207. @classmethod
  208. def getsequence(cls, conn, pkseq=None):
  209. """
  210. Return a sequence number.
  211. This allows us to quickly get the last inserted row id.
  212. """
  213. if pkseq is None:
  214. pkseq = cls.pkseq
  215. if pkseq is None:
  216. if cls.table is None:
  217. raise MormError("No table specified for "
  218. "getting sequence value")
  219. # By default use PostgreSQL convention.
  220. pkseq = '%s_id_seq' % cls.table
  221. # Run the query.
  222. assert conn
  223. cursor = conn.cursor()
  224. cursor.execute("SELECT currval(%s)", (pkseq,))
  225. seq = cursor.fetchone()[0]
  226. return seq
  227. #---------------------------------------------------------------------------
  228. # Methods that write to the connection
  229. @classmethod
  230. def execute(cls, conn, query, args=None, objcls=None):
  231. """
  232. Execute an arbitrary read-write SQL statement and return a decoder for
  233. the results.
  234. """
  235. assert conn
  236. cursor = conn.cursor()
  237. cursor.execute(query, args)
  238. # Get a decoder with the cursor results.
  239. dec = MormDecoder(cls, cursor)
  240. # Return an iterator over the cursor.
  241. return dec.iter(cursor, objcls)
  242. @classmethod
  243. def insert(cls, conn, cond=None, args=None, **fields):
  244. """
  245. Convenience method that creates an encoder and executes an insert
  246. statement. Returns the encoder.
  247. """
  248. enc = cls.encoder(**fields)
  249. return enc.insert(conn, cond, args)
  250. @classmethod
  251. def create(cls, conn, cond=None, args=None, pk='id', **fields):
  252. """
  253. Convenience method that creates an encoder and executes an insert
  254. statement, and then fetches the data back from the database (because of
  255. defaults) and returns the new object.
  256. Note: this assumes that the primary key is composed of a single column.
  257. Note2: this does NOT commit the transaction.
  258. """
  259. cls.insert(conn, cond, args, **fields)
  260. pkseq = '%s_%s_seq' % (cls.table, pk)
  261. seq = cls.getsequence(conn, pkseq)
  262. return cls.get(conn, **{pk: seq})
  263. @classmethod
  264. def update(cls, conn, cond=None, args=None, **fields):
  265. """
  266. Convenience method that creates an encoder and executes an update
  267. statement. Returns the encoder.
  268. """
  269. enc = cls.encoder(**fields)
  270. return enc.update(conn, cond, args)
  271. @classmethod
  272. def delete(cls, conn, cond=None, args=None):
  273. """
  274. Convenience method that deletes rows with the given condition. WARNING:
  275. if you do not specify any condition, this deletes all the rows in the
  276. table! (just like SQL)
  277. """
  278. if cond is None:
  279. cond = ''
  280. if args is None:
  281. args = []
  282. # Run the query.
  283. assert conn
  284. cursor = conn.cursor()
  285. cursor.execute("DELETE FROM %s %s" % (cls.table, cond),
  286. list(args))
  287. return cursor
  288. class MormError(Exception):
  289. """
  290. Error happening in this module.
  291. """
  292. class MormConv(object):
  293. """
  294. Base class for all automated type converters.
  295. """
  296. def from_python(self, value):
  297. """
  298. Convert value from Python into a type suitable for insertion in a
  299. database query.
  300. """
  301. return value
  302. def to_python(self, value):
  303. """
  304. Convert value from the type given by the database connection into a
  305. Python type.
  306. """
  307. return value
  308. # Encoding from the DBAPI-2.0 client interface.
  309. dbapi_encoding = 'UTF-8'
  310. class MormConvUnicode(MormConv):
  311. """
  312. Conversion between database-encoded string to unicode type.
  313. """
  314. def from_python(self, vuni):
  315. if isinstance(vuni, str):
  316. vuni = vuni.decode()
  317. return vuni # Keep as unicode, DBAPI takes care of encoding properly.
  318. def to_python(self, vstr):
  319. if vstr is not None:
  320. return vstr.decode(dbapi_encoding)
  321. class MormConvString(MormConv):
  322. """
  323. Conversion between database-encoded string to unicode type.
  324. """
  325. # Default value for the desired encoding for the string.
  326. encoding = 'ISO-8859-1'
  327. def __init__(self, encoding=None):
  328. MormConv.__init__(self)
  329. if encoding:
  330. self.encoding = encoding
  331. self.sameenc = (encoding == dbapi_encoding)
  332. def from_python(self, vuni):
  333. if isinstance(vuni, str):
  334. vuni = vuni.decode(self.encoding)
  335. # Send as unicode, DBAPI takes care of encoding with the appropriate
  336. # client encoding.
  337. return vuni
  338. def to_python(self, vstr):
  339. if vstr is not None:
  340. if self.sameenc:
  341. return vstr
  342. else:
  343. return vstr.decode(dbapi_encoding).encode(self.encoding)
  344. class MormEndecBase(object):
  345. """
  346. Base class for classes that accept list of tables.
  347. """
  348. def __init__(self, tables):
  349. # Accept multiple formats for tables list.
  350. self.tables = []
  351. if not isinstance(tables, (tuple, list)):
  352. assert issubclass(tables, MormTable)
  353. tables = (tables,)
  354. for cls in tables:
  355. assert issubclass(cls, MormTable)
  356. self.tables = tuple(tables)
  357. """Tables is a list of tables that this decoder will use, in order. You
  358. can also pass in a single table class, or a sequence of table"""
  359. assert self.tables
  360. def table(self):
  361. return self.tables[0].tname()
  362. def tablenames(self):
  363. return ','.join(x.tname() for x in self.tables)
  364. class MormDecoder(MormEndecBase):
  365. """
  366. Decoder class that takes care of creating instances with appropriate
  367. attributes for a specific row.
  368. """
  369. def __init__(self, tables, desc):
  370. MormEndecBase.__init__(self, tables)
  371. if isinstance(desc, (tuple, list)):
  372. colnames = desc
  373. else:
  374. assert desc is not None
  375. colnames = [x[0] for x in desc.description]
  376. assert colnames
  377. self.colnames = colnames
  378. """List of column names to restrict decoding.."""
  379. # Note: dotted notation inputs are ignored for now.
  380. #
  381. # if colnames is not None: # Remove dotted notation if present.
  382. # self.colnames = [c.split('.')[-1] for c in colnames]
  383. self.attrnames = dict((c, c.split('.')[-1]) for c in colnames)
  384. assert len(self.attrnames) == len(self.colnames)
  385. def cols(self):
  386. """
  387. Return a list of field names, suitable for insertion in a query.
  388. """
  389. return ', '.join(self.colnames)
  390. def decode(self, row, obj=None, objcls=None):
  391. """
  392. Decode a row.
  393. """
  394. if len(self.colnames) != len(row):
  395. raise MormError("Row has incorrect length for decoder.")
  396. # Convert all the values right away. We assume that the query is
  397. # minimal and that we're going to need to access all the values.
  398. if obj is None:
  399. if objcls is not None:
  400. # Use the given class if present.
  401. obj = objcls()
  402. else:
  403. # Otherwise look in the list of tables, one-by-one until we find
  404. # an object class to use.
  405. for table in self.tables:
  406. if table.objcls is not None:
  407. obj = table.objcls()
  408. break
  409. else:
  410. # Otherwise just use the default
  411. obj = MormObject()
  412. for cname, cvalue in zip(self.colnames, row):
  413. if '.' in cname:
  414. # Get the table with the matching name and use the converter on
  415. # this table if there is one.
  416. comps = cname.split('.')
  417. tablename, cname = comps[0], comps[-1]
  418. for cls in self.tables:
  419. if cls.tname() == tablename:
  420. converter = cls.converters.get(cname, None)
  421. if converter is not None:
  422. cvalue = converter.to_python(cvalue)
  423. break
  424. else:
  425. # Look in the table list for the first appropriate found
  426. # converter.
  427. for cls in self.tables:
  428. converter = cls.converters.get(cname, None)
  429. if converter is not None:
  430. cvalue = converter.to_python(cvalue)
  431. break
  432. ## setattr(obj, self.attrnames[cname], cvalue)
  433. setattr(obj, cname, cvalue)
  434. return obj
  435. def iter(self, cursor, objcls=None):
  436. """
  437. Create an iterator on the given cursor.
  438. This also deals with the case where a cursor has no results.
  439. """
  440. if cursor is None:
  441. raise MormError("No cursor to iterate.")
  442. return MormDecoderIterator(self, cursor, objcls)
  443. #---------------------------------------------------------------------------
  444. @staticmethod
  445. def do_select(conn, tables, colnames=None, cond=None, condargs=None,
  446. distinct=None):
  447. """
  448. Guts of the select methods. You need to pass in a valid connection
  449. 'conn'. This returns a new cursor from the given connection.
  450. Note that this method is limited to be able to select on a single table
  451. only. If you want to select on multiple tables at once you will need to
  452. do the select yourself.
  453. """
  454. tablenames = ','.join(x.tname() for x in tables)
  455. if colnames is None:
  456. colnames = ('*',)
  457. if cond is None:
  458. cond = ''
  459. if condargs is None:
  460. condargs = []
  461. else:
  462. assert isinstance(condargs, (tuple, list, dict))
  463. assert conn is not None
  464. # Run the query.
  465. cursor = conn.cursor()
  466. distinct = distinct and 'DISTINCT' or ''
  467. sql = "SELECT %s %s FROM %s %s" % (distinct, ', '.join(colnames),
  468. tablenames, cond)
  469. cursor.execute(sql, condargs)
  470. return cursor
  471. class MormDecoderIterator(object):
  472. """
  473. Iterator for a decoder.
  474. """
  475. def __init__(self, decoder, cursor, objcls=None):
  476. self.decoder = decoder
  477. self.cursor = cursor
  478. self.objcls = objcls
  479. def __len__(self):
  480. return self.cursor.rowcount
  481. def __iter__(self):
  482. return self
  483. def next(self, obj=None, objcls=None):
  484. if self.cursor.rowcount == 0:
  485. raise StopIteration
  486. if objcls is None:
  487. objcls = self.objcls
  488. row = self.cursor.fetchone()
  489. if row is None:
  490. raise StopIteration
  491. else:
  492. return self.decoder.decode(row, obj, objcls)
  493. class MormEncoder(MormEndecBase):
  494. """
  495. Encoder class. This class converts and contains a set of argument according
  496. to declared table conversions. This is mainly used to create INSERT or
  497. UPDATE statements.
  498. """
  499. def __init__(self, tables, fields):
  500. MormEndecBase.__init__(self, tables)
  501. self.colnames = []
  502. """Names of all the columns of the encoder."""
  503. self.colvalues = []
  504. """Encoded values of all the fields of the encoder."""
  505. # Set column names and values, converting if necessary.
  506. for cname, cvalue in list(fields.items()):
  507. self.colnames.append(cname)
  508. # Apply converter to value if necessary
  509. for cls in self.tables:
  510. converter = cls.converters.get(cname, None)
  511. if converter is not None:
  512. cvalue = converter.from_python(cvalue)
  513. break
  514. self.colvalues.append(cvalue)
  515. def cols(self):
  516. return ', '.join(self.colnames)
  517. def values(self):
  518. """
  519. Returns the list of converted values.
  520. This is useful to let DBAPI do the automatic quoting.
  521. """
  522. return self.colvalues
  523. def plhold(self):
  524. """
  525. Returns a string for holding replacement values in the query string,
  526. e.g.: %s, %s, %s
  527. """
  528. return ', '.join(['%s'] * len(self.colvalues))
  529. def set(self):
  530. """
  531. Returns a string for holding 'set values' syntax in the query string,
  532. e.g.: col1 = %s, col2 = %s, col3 = %s
  533. """
  534. return ', '.join(('%s = %%s' % x) for x in self.colnames)
  535. def insert(self, conn, cond=None, args=None):
  536. """
  537. Execute a simple insert statement with the contained values. You can
  538. only use this on a single table for now. Note: this does not commit the
  539. connection.
  540. """
  541. assert len(self.tables) == 1
  542. if cond is None:
  543. cond = ''
  544. if args is None:
  545. args = []
  546. # We must be given a valid connection in 'conn'.
  547. assert conn
  548. # Run the query.
  549. cursor = conn.cursor()
  550. sql = ("INSERT INTO %s (%s) VALUES (%s) %s" %
  551. (self.table(), self.cols(), self.plhold(), cond))
  552. cursor.execute(sql, list(self.values()) + list(args))
  553. return cursor
  554. def update(self, conn, cond=None, args=None):
  555. """
  556. Execute a simple update statement with the contained values. You can
  557. only use this on a single table for now. Note: this does not commit the
  558. connection. If you supply your own connection, we return the cursor
  559. that we used for the query.
  560. """
  561. assert len(self.tables) == 1
  562. if cond is None:
  563. cond = ''
  564. if args is None:
  565. args = []
  566. # We must be given a valid connection in 'conn'.
  567. assert conn
  568. # Run the query.
  569. cursor = conn.cursor()
  570. sql = "UPDATE %s SET %s %s" % (self.table(), self.set(), cond)
  571. cursor.execute(sql, list(self.values()) + list(args))
  572. return cursor