named_query.py
上传用户:king477883
上传日期:2021-03-01
资源大小:9553k
文件大小:25k
源码类别:

游戏引擎

开发平台:

C++ Builder

  1. """
  2. @file named_query.py
  3. @author Ryan Williams, Phoenix
  4. @date 2007-07-31
  5. @brief An API for running named queries.
  6. $LicenseInfo:firstyear=2007&license=mit$
  7. Copyright (c) 2007-2010, Linden Research, Inc.
  8. Permission is hereby granted, free of charge, to any person obtaining a copy
  9. of this software and associated documentation files (the "Software"), to deal
  10. in the Software without restriction, including without limitation the rights
  11. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  12. copies of the Software, and to permit persons to whom the Software is
  13. furnished to do so, subject to the following conditions:
  14. The above copyright notice and this permission notice shall be included in
  15. all copies or substantial portions of the Software.
  16. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  22. THE SOFTWARE.
  23. $/LicenseInfo$
  24. """
  25. import errno
  26. import MySQLdb
  27. import MySQLdb.cursors
  28. import os
  29. import os.path
  30. import re
  31. import time
  32. #import sys # *TODO: remove. only used in testing.
  33. #import pprint # *TODO: remove. only used in testing.
  34. try:
  35.     set = set
  36. except NameError:
  37.     from sets import Set as set
  38. from indra.base import llsd
  39. from indra.base import config
  40. DEBUG = False
  41. NQ_FILE_SUFFIX = config.get('named-query-file-suffix', '.nq')
  42. NQ_FILE_SUFFIX_LEN  = len(NQ_FILE_SUFFIX)
  43. _g_named_manager = None
  44. def _init_g_named_manager(sql_dir = None):
  45.     """Initializes a global NamedManager object to point at a
  46.     specified named queries hierarchy.
  47.     This function is intended entirely for testing purposes,
  48.     because it's tricky to control the config from inside a test."""
  49.     global NQ_FILE_SUFFIX
  50.     NQ_FILE_SUFFIX = config.get('named-query-file-suffix', '.nq')
  51.     global NQ_FILE_SUFFIX_LEN
  52.     NQ_FILE_SUFFIX_LEN  = len(NQ_FILE_SUFFIX)
  53.     if sql_dir is None:
  54.         sql_dir = config.get('named-query-base-dir')
  55.     # extra fallback directory in case config doesn't return what we want
  56.     if sql_dir is None:
  57.         sql_dir = os.path.abspath(
  58.             os.path.join(
  59.             os.path.realpath(os.path.dirname(__file__)), "..", "..", "..", "..", "web", "dataservice", "sql"))
  60.     global _g_named_manager
  61.     _g_named_manager = NamedQueryManager(
  62.         os.path.abspath(os.path.realpath(sql_dir)))
  63. def get(name, schema = None):
  64.     "Get the named query object to be used to perform queries"
  65.     if _g_named_manager is None:
  66.         _init_g_named_manager()
  67.     return _g_named_manager.get(name).for_schema(schema)
  68. def sql(connection, name, params):
  69.     # use module-global NamedQuery object to perform default substitution
  70.     return get(name).sql(connection, params)
  71. def run(connection, name, params, expect_rows = None):
  72.     """
  73. @brief given a connection, run a named query with the params
  74. Note that this function will fetch ALL rows.
  75. @param connection The connection to use
  76. @param name The name of the query to run
  77. @param params The parameters passed into the query
  78. @param expect_rows The number of rows expected. Set to 1 if return_as_map is true.  Raises ExpectationFailed if the number of returned rows doesn't exactly match.  Kind of a hack.
  79. @return Returns the result set as a list of dicts.
  80. """
  81.     return get(name).run(connection, params, expect_rows)
  82. class ExpectationFailed(Exception):
  83.     """ Exception that is raised when an expectation for an sql query
  84.     is not met."""
  85.     def __init__(self, message):
  86.         Exception.__init__(self, message)
  87.         self.message = message
  88. class NamedQuery(object):
  89.     def __init__(self, name, filename):
  90.         """ Construct a NamedQuery object.  The name argument is an
  91.         arbitrary name as a handle for the query, and the filename is
  92.         a path to a file or a file-like object containing an llsd named
  93.         query document."""
  94.         self._stat_interval_seconds = 5  # 5 seconds
  95.         self._name = name
  96.         if (filename is not None and isinstance(filename, (str, unicode))
  97.             and NQ_FILE_SUFFIX != filename[-NQ_FILE_SUFFIX_LEN:]):
  98.             filename = filename + NQ_FILE_SUFFIX
  99.         self._location = filename
  100.         self._alternative = dict()
  101.         self._last_mod_time = 0
  102.         self._last_check_time = 0
  103.         self.deleted = False
  104.         self.load_contents()
  105.     def name(self):
  106.         """ The name of the query. """
  107.         return self._name
  108.     def get_modtime(self):
  109.         """ Returns the mtime (last modified time) of the named query
  110.         filename. For file-like objects, expect a modtime of 0"""
  111.         if self._location and isinstance(self._location, (str, unicode)):
  112.             return os.path.getmtime(self._location)
  113.         return 0
  114.     def load_contents(self):
  115.         """ Loads and parses the named query file into self.  Does
  116.         nothing if self.location is nonexistant."""
  117.         if self._location:
  118.             if isinstance(self._location, (str, unicode)):
  119.                 contents = llsd.parse(open(self._location).read())
  120.             else:
  121.                 # we probably have a file-like object. Godspeed!
  122.                 contents = llsd.parse(self._location.read())
  123.             self._reference_contents(contents)
  124.             # Check for alternative implementations
  125.             try:
  126.                 for name, alt in self._contents['alternative'].items():
  127.                     nq = NamedQuery(name, None)
  128.                     nq._reference_contents(alt)
  129.                     self._alternative[name] = nq
  130.             except KeyError, e:
  131.                 pass
  132.             self._last_mod_time = self.get_modtime()
  133.             self._last_check_time = time.time()
  134.     def _reference_contents(self, contents):
  135.         "Helper method which builds internal structure from parsed contents"
  136.         self._contents = contents
  137.         self._ttl = int(self._contents.get('ttl', 0))
  138.         self._return_as_map = bool(self._contents.get('return_as_map', False))
  139.         self._legacy_dbname = self._contents.get('legacy_dbname', None)
  140.         # reset these before doing the sql conversion because we will
  141.         # read them there. reset these while loading so we pick up
  142.         # changes.
  143.         self._around = set()
  144.         self._append = set()
  145.         self._integer = set()
  146.         self._options = self._contents.get('dynamic_where', {})
  147.         for key in self._options:
  148.             if isinstance(self._options[key], basestring):
  149.                 self._options[key] = self._convert_sql(self._options[key])
  150.             elif isinstance(self._options[key], list):
  151.                 lines = []
  152.                 for line in self._options[key]:
  153.                     lines.append(self._convert_sql(line))
  154.                 self._options[key] = lines
  155.             else:
  156.                 moreopt = {}
  157.                 for kk in self._options[key]:
  158.                     moreopt[kk] = self._convert_sql(self._options[key][kk]) 
  159.                 self._options[key] = moreopt
  160.         self._base_query = self._convert_sql(self._contents['base_query'])
  161.         self._query_suffix = self._convert_sql(
  162.             self._contents.get('query_suffix', ''))
  163.     def _convert_sql(self, sql):
  164.         """convert the parsed sql into a useful internal structure.
  165.         This function has to turn the named query format into a pyformat
  166.         style. It also has to look for %:name% and :name% and
  167.         ready them for use in LIKE statements"""
  168.         if sql:
  169.             #print >>sys.stderr, "sql:",sql
  170.             
  171.             # This first sub is to properly escape any % signs that
  172.             # are meant to be literally passed through to mysql in the
  173.             # query.  It leaves any %'s that are used for
  174.             # like-expressions.
  175.             expr = re.compile("(?<=[^a-zA-Z0-9_-])%(?=[^:])")
  176.             sql = expr.sub('%%', sql)
  177.             # This should tackle the rest of the %'s in the query, by
  178.             # converting them to LIKE clauses.
  179.             expr = re.compile("(%?):([a-zA-Z][a-zA-Z0-9_-]*)%")
  180.             sql = expr.sub(self._prepare_like, sql)
  181.             expr = re.compile("#:([a-zA-Z][a-zA-Z0-9_-]*)")
  182.             sql = expr.sub(self._prepare_integer, sql)
  183.             expr = re.compile(":([a-zA-Z][a-zA-Z0-9_-]*)")
  184.             sql = expr.sub("%(\1)s", sql)
  185.         return sql
  186.     def _prepare_like(self, match):
  187.         """This function changes LIKE statement replace behavior
  188.         It works by turning %:name% to %(_name_around)s and :name% to
  189.         %(_name_append)s. Since a leading '_' is not a valid keyname
  190.         input (enforced via unit tests), it will never clash with
  191.         existing keys. Then, when building the statement, the query
  192.         runner will generate corrected strings."""
  193.         if match.group(1) == '%':
  194.             # there is a leading % so this is treated as prefix/suffix
  195.             self._around.add(match.group(2))
  196.             return "%(" + self._build_around_key(match.group(2)) + ")s"
  197.         else:
  198.             # there is no leading %, so this is suffix only
  199.             self._append.add(match.group(2))
  200.             return "%(" + self._build_append_key(match.group(2)) + ")s"
  201.     def _build_around_key(self, key):
  202.         return "_" + key + "_around"
  203.     def _build_append_key(self, key):
  204.         return "_" + key + "_append"
  205.     def _prepare_integer(self, match):
  206.         """This function adjusts the sql for #:name replacements
  207.         It works by turning #:name to %(_name_as_integer)s. Since a
  208.         leading '_' is not a valid keyname input (enforced via unit
  209.         tests), it will never clash with existing keys. Then, when
  210.         building the statement, the query runner will generate
  211.         corrected strings."""
  212.         self._integer.add(match.group(1))
  213.         return "%(" + self._build_integer_key(match.group(1)) + ")s"
  214.     def _build_integer_key(self, key):
  215.         return "_" + key + "_as_integer"
  216.     def _strip_wildcards_to_list(self, value):
  217.         """Take string, and strip out the LIKE special characters.
  218.         Technically, this is database dependant, but postgresql and
  219.         mysql use the same wildcards, and I am not aware of a general
  220.         way to handle this. I think you need a sql statement of the
  221.         form:
  222.         LIKE_STRING( [ANY,ONE,str]... )
  223.         which would treat ANY as their any string, and ONE as their
  224.         single glyph, and str as something that needs database
  225.         specific encoding to not allow any % or _ to affect the query.
  226.         As it stands, I believe it's impossible to write a named query
  227.         style interface which uses like to search the entire space of
  228.         text available. Imagine the query:
  229.         % of brain used by average linden
  230.         In order to search for %, it must be escaped, so once you have
  231.         escaped the string to not do wildcard searches, and be escaped
  232.         for the database, and then prepended the wildcard you come
  233.         back with one of:
  234.         1) %% of brain used by average linden
  235.         2) %%% of brain used by average linden
  236.         Then, when passed to the database to be escaped to be database
  237.         safe, you get back:
  238.         
  239.         1) %\% of brain used by average linden
  240.         : which means search for any character sequence, followed by a
  241.           backslash, followed by any sequence, followed by ' of
  242.           brain...'
  243.         2) %%% of brain used by average linden
  244.         : which (I believe) means search for a % followed by any
  245.           character sequence followed by 'of brain...'
  246.         Neither of which is what we want!
  247.         So, we need a vendor (or extention) for LIKE_STRING. Anyone
  248.         want to write it?"""
  249.         if isinstance(value, unicode):
  250.             utf8_value = value
  251.         else:
  252.             utf8_value = unicode(value, "utf-8")
  253.         esc_list = []
  254.         remove_chars = set(u"%_")
  255.         for glyph in utf8_value:
  256.             if glyph in remove_chars:
  257.                 continue
  258.             esc_list.append(glyph.encode("utf-8"))
  259.         return esc_list
  260.     def delete(self):
  261.         """ Makes this query unusable by deleting all the members and
  262.         setting the deleted member.  This is desired when the on-disk
  263.         query has been deleted but the in-memory copy remains."""
  264.         # blow away all members except _name, _location, and deleted
  265.         name, location = self._name, self._location
  266.         for key in self.__dict__.keys():
  267.             del self.__dict__[key]
  268.         self.deleted = True
  269.         self._name, self._location = name, location
  270.     def ttl(self):
  271.         """ Estimated time to live of this query. Used for web
  272.         services to set the Expires header."""
  273.         return self._ttl
  274.     def legacy_dbname(self):
  275.         return self._legacy_dbname
  276.     def return_as_map(self):
  277.         """ Returns true if this query is configured to return its
  278.         results as a single map (as opposed to a list of maps, the
  279.         normal behavior)."""
  280.         
  281.         return self._return_as_map
  282.     def for_schema(self, db_name):
  283.         "Look trough the alternates and return the correct query"
  284.         if db_name is None:
  285.             return self
  286.         try:
  287.             return self._alternative[db_name]
  288.         except KeyError, e:
  289.             pass
  290.         return self
  291.     def run(self, connection, params, expect_rows = None, use_dictcursor = True):
  292.         """given a connection, run a named query with the params
  293.         Note that this function will fetch ALL rows. We do this because it
  294.         opens and closes the cursor to generate the values, and this 
  295.         isn't a generator so the cursor has no life beyond the method call.
  296.         @param cursor The connection to use (this generates its own cursor for the query)
  297.         @param name The name of the query to run
  298.         @param params The parameters passed into the query
  299.         @param expect_rows The number of rows expected. Set to 1 if return_as_map is true.  Raises ExpectationFailed if the number of returned rows doesn't exactly match.  Kind of a hack.
  300.         @param use_dictcursor Set to false to use a normal cursor and manually convert the rows to dicts.
  301.         @return Returns the result set as a list of dicts, or, if the named query has return_as_map set to true, returns a single dict.
  302.         """
  303.         if use_dictcursor:
  304.             cursor = connection.cursor(MySQLdb.cursors.DictCursor)
  305.         else:
  306.             cursor = connection.cursor()
  307.         full_query, params = self._construct_sql(params)
  308.         if DEBUG:
  309.             print "SQL:", self.sql(connection, params)
  310.         rows = cursor.execute(full_query, params)
  311.         # *NOTE: the expect_rows argument is a very cheesy way to get some
  312.         # validation on the result set.  If you want to add more expectation
  313.         # logic, do something more object-oriented and flexible. Or use an ORM.
  314.         if(self._return_as_map):
  315.             expect_rows = 1
  316.         if expect_rows is not None and rows != expect_rows:
  317.             cursor.close()
  318.             raise ExpectationFailed("Statement expected %s rows, got %s.  Sql: '%s' %s" % (
  319.                 expect_rows, rows, full_query, params))
  320.         # convert to dicts manually if we're not using a dictcursor
  321.         if use_dictcursor:
  322.             result_set = cursor.fetchall()
  323.         else:
  324.             if cursor.description is None:
  325.                 # an insert or something
  326.                 x = cursor.fetchall()
  327.                 cursor.close()
  328.                 return x
  329.             names = [x[0] for x in cursor.description]
  330.             result_set = []
  331.             for row in cursor.fetchall():
  332.                 converted_row = {}
  333.                 for idx, col_name in enumerate(names):
  334.                     converted_row[col_name] = row[idx]
  335.                 result_set.append(converted_row)
  336.         cursor.close()
  337.         if self._return_as_map:
  338.             return result_set[0]
  339.         return result_set
  340.     def _construct_sql(self, params):
  341.         """ Returns a query string and a dictionary of parameters,
  342.         suitable for directly passing to the execute() method."""
  343.         self.refresh()
  344.         # build the query from the options available and the params
  345.         base_query = []
  346.         base_query.append(self._base_query)
  347.         #print >>sys.stderr, "base_query:",base_query
  348.         for opt, extra_where in self._options.items():
  349.             if type(extra_where) in (dict, list, tuple):
  350.                 if opt in params:
  351.                     base_query.append(extra_where[params[opt]])
  352.             else:
  353.                 if opt in params and params[opt]:
  354.                     base_query.append(extra_where)
  355.         if self._query_suffix:
  356.             base_query.append(self._query_suffix)
  357.         #print >>sys.stderr, "base_query:",base_query
  358.         full_query = 'n'.join(base_query)
  359.         # Go through the query and rewrite all of the ones with the
  360.         # @:name syntax.
  361.         rewrite = _RewriteQueryForArray(params)
  362.         expr = re.compile("@%(([a-zA-Z][a-zA-Z0-9_-]*))s")
  363.         full_query = expr.sub(rewrite.operate, full_query)
  364.         params.update(rewrite.new_params)
  365.         # build out the params for like. We only have to do this
  366.         # parameters which were detected to have ued the where syntax
  367.         # during load.
  368.         #
  369.         # * treat the incoming string as utf-8
  370.         # * strip wildcards
  371.         # * append or prepend % as appropriate
  372.         new_params = {}
  373.         for key in params:
  374.             if key in self._around:
  375.                 new_value = ['%']
  376.                 new_value.extend(self._strip_wildcards_to_list(params[key]))
  377.                 new_value.append('%')
  378.                 new_params[self._build_around_key(key)] = ''.join(new_value)
  379.             if key in self._append:
  380.                 new_value = self._strip_wildcards_to_list(params[key])
  381.                 new_value.append('%')
  382.                 new_params[self._build_append_key(key)] = ''.join(new_value)
  383.             if key in self._integer:
  384.                 new_params[self._build_integer_key(key)] = int(params[key])
  385.         params.update(new_params)
  386.         return full_query, params
  387.     def sql(self, connection, params):
  388.         """ Generates an SQL statement from the named query document
  389.         and a dictionary of parameters.
  390.         *NOTE: Only use for debugging, because it uses the
  391.          non-standard MySQLdb 'literal' method.
  392.         """
  393.         if not DEBUG:
  394.             import warnings
  395.             warnings.warn("Don't use named_query.sql() when not debugging.  Used on %s" % self._location)
  396.         # do substitution using the mysql (non-standard) 'literal'
  397.         # function to do the escaping.
  398.         full_query, params = self._construct_sql(params)
  399.         return full_query % connection.literal(params)
  400.         
  401.     def refresh(self):
  402.         """ Refresh self from the file on the filesystem.
  403.         This is optimized to be callable as frequently as you wish,
  404.         without adding too much load.  It does so by only stat-ing the
  405.         file every N seconds, where N defaults to 5 and is
  406.         configurable through the member _stat_interval_seconds.  If the stat
  407.         reveals that the file has changed, refresh will re-parse the
  408.         contents of the file and use them to update the named query
  409.         instance.  If the stat reveals that the file has been deleted,
  410.         refresh will call self.delete to make the in-memory
  411.         representation unusable."""
  412.         now = time.time()
  413.         if(now - self._last_check_time > self._stat_interval_seconds):
  414.             self._last_check_time = now
  415.             try:
  416.                 modtime = self.get_modtime()
  417.                 if(modtime > self._last_mod_time):
  418.                     self.load_contents()
  419.             except OSError, e:
  420.                 if e.errno == errno.ENOENT: # file not found
  421.                     self.delete() # clean up self
  422.                 raise  # pass the exception along to the caller so they know that this query disappeared
  423. class NamedQueryManager(object):
  424.     """ Manages the lifespan of NamedQuery objects, drawing from a
  425.     directory hierarchy of named query documents.
  426.     In practice this amounts to a memory cache of NamedQuery objects."""
  427.     
  428.     def __init__(self, named_queries_dir):
  429.         """ Initializes a manager to look for named queries in a
  430.         directory."""
  431.         self._dir = os.path.abspath(os.path.realpath(named_queries_dir))
  432.         self._cached_queries = {}
  433.     def sql(self, connection, name, params):
  434.         nq = self.get(name)
  435.         return nq.sql(connection, params)
  436.         
  437.     def get(self, name):
  438.         """ Returns a NamedQuery instance based on the name, either
  439.         from memory cache, or by parsing from disk.
  440.         The name is simply a relative path to the directory associated
  441.         with the manager object.  Before returning the instance, the
  442.         NamedQuery object is cached in memory, so that subsequent
  443.         accesses don't have to read from disk or do any parsing.  This
  444.         means that NamedQuery objects returned by this method are
  445.         shared across all users of the manager object.
  446.         NamedQuery.refresh is used to bring the NamedQuery objects in
  447.         sync with the actual files on disk."""
  448.         nq = self._cached_queries.get(name)
  449.         if nq is None:
  450.             nq = NamedQuery(name, os.path.join(self._dir, name))
  451.             self._cached_queries[name] = nq
  452.         else:
  453.             try:
  454.                 nq.refresh()
  455.             except OSError, e:
  456.                 if e.errno == errno.ENOENT: # file not found
  457.                     del self._cached_queries[name]
  458.                 raise # pass exception along to caller so they know that the query disappeared
  459.         return nq
  460. class _RewriteQueryForArray(object):
  461.     "Helper class for rewriting queries with the @:name syntax"
  462.     def __init__(self, params):
  463.         self.params = params
  464.         self.new_params = dict()
  465.     def operate(self, match):
  466.         "Given a match, return the string that should be in use"
  467.         key = match.group(1)
  468.         value = self.params[key]
  469.         if type(value) in (list,tuple):
  470.             rv = []
  471.             for idx in range(len(value)):
  472.                 # if the value@idx is array-like, we are
  473.                 # probably dealing with a VALUES
  474.                 new_key = "_%s_%s"%(key, str(idx))
  475.                 val_item = value[idx]
  476.                 if type(val_item) in (list, tuple, dict):
  477.                     if type(val_item) is dict:
  478.                         # this is because in Python, the order of 
  479.                         # key, value retrieval from the dict is not
  480.                         # guaranteed to match what the input intended
  481.                         # and for VALUES, order is important.
  482.                         # TODO: Implemented ordered dict in LLSD parser?
  483.                         raise ExpectationFailed('Only lists/tuples allowed,
  484.                                 received dict')
  485.                     values_keys = []
  486.                     for value_idx, item in enumerate(val_item):
  487.                         # we want a key of the format :
  488.                         # key_#replacement_#value_row_#value_col
  489.                         # ugh... so if we are replacing 10 rows in user_note, 
  490.                         # the first values clause would read (for @:user_notes) :-
  491.                         # ( :_user_notes_0_1_1,  :_user_notes_0_1_2, :_user_notes_0_1_3 )
  492.                         # the input LLSD for VALUES will look like:
  493.                         # <llsd>...
  494.                         # <map>
  495.                         #  <key>user_notes</key>
  496.                         #      <array>
  497.                         #      <array> <!-- row 1 for VALUES -->
  498.                         #          <string>...</string>
  499.                         #          <string>...</string>
  500.                         #          <string>...</string>
  501.                         #      </array>
  502.                         # ...
  503.                         #      </array>
  504.                         # </map>
  505.                         # ... </llsd>
  506.                         values_key = "%s_%s"%(new_key, value_idx)
  507.                         self.new_params[values_key] = item
  508.                         values_keys.append("%%(%s)s"%values_key)
  509.                     # now collapse all these new place holders enclosed in ()
  510.                     # from [':_key_0_1_1', ':_key_0_1_2', ':_key_0_1_3,...] 
  511.                     # rv will have [ '(:_key_0_1_1, :_key_0_1_2, :_key_0_1_3)', ]
  512.                     # which is flattened a few lines below join(rv)
  513.                     rv.append('(%s)' % ','.join(values_keys))
  514.                 else:
  515.                     self.new_params[new_key] = val_item
  516.                     rv.append("%%(%s)s"%new_key)
  517.             return ','.join(rv)
  518.         else:
  519.             # not something that can be expanded, so just drop the
  520.             # leading @ in the front of the match. This will mean that
  521.             # the single value we have, be it a string, int, whatever
  522.             # (other than dict) will correctly show up, eg:
  523.             #
  524.             # where foo in (@:foobar) -- foobar is a string, so we get
  525.             # where foo in (:foobar)
  526.             return match.group(0)[1:]