group db-related stuff in sfa/storage
[sfa.git] / sfa / storage / filter.py
1 from types import StringTypes
2 import pgdb
3  
4 from sfa.util.faults import SfaInvalidArgument
5
6 from sfa.storage.parameter import Parameter, Mixed, python_type
7
8 class Filter(Parameter, dict):
9     """
10     A type of parameter that represents a filter on one or more
11     columns of a database table.
12     Special features provide support for negation, upper and lower bounds, 
13     as well as sorting and clipping.
14
15
16     fields should be a dictionary of field names and types
17     Only filters on non-sequence type fields are supported.
18     example : fields = {'node_id': Parameter(int, "Node identifier"),
19                         'hostname': Parameter(int, "Fully qualified hostname", max = 255),
20                         ...}
21
22
23     filter should be a dictionary of field names and values
24     representing  the criteria for filtering. 
25     example : filter = { 'hostname' : '*.edu' , site_id : [34,54] }
26     Whether the filter represents an intersection (AND) or a union (OR) 
27     of these criteria is determined by the join_with argument 
28     provided to the sql method below
29
30     Special features:
31
32     * a field starting with the ~ character means negation.
33     example :  filter = { '~peer_id' : None }
34
35     * a field starting with < [  ] or > means lower than or greater than
36       < > uses strict comparison
37       [ ] is for using <= or >= instead
38     example :  filter = { ']event_id' : 2305 }
39     example :  filter = { '>time' : 1178531418 }
40       in this example the integer value denotes a unix timestamp
41
42     * if a value is a sequence type, then it should represent 
43       a list of possible values for that field
44     example : filter = { 'node_id' : [12,34,56] }
45
46     * a (string) value containing either a * or a % character is
47       treated as a (sql) pattern; * are replaced with % that is the
48       SQL wildcard character.
49     example :  filter = { 'hostname' : '*.jp' } 
50
51     * fields starting with - are special and relate to row selection, i.e. sorting and clipping
52     * '-SORT' : a field name, or an ordered list of field names that are used for sorting
53       these fields may start with + (default) or - for denoting increasing or decreasing order
54     example : filter = { '-SORT' : [ '+node_id', '-hostname' ] }
55     * '-OFFSET' : the number of first rows to be ommitted
56     * '-LIMIT' : the amount of rows to be returned 
57     example : filter = { '-OFFSET' : 100, '-LIMIT':25}
58
59     A realistic example would read
60     GetNodes ( { 'node_type' : 'regular' , 'hostname' : '*.edu' , '-SORT' : 'hostname' , '-OFFSET' : 30 , '-LIMIT' : 25 } )
61     and that would return regular (usual) nodes matching '*.edu' in alphabetical order from 31th to 55th
62     """
63
64     def __init__(self, fields = {}, filter = {}, doc = "Attribute filter"):
65         # Store the filter in our dict instance
66         valid_fields = {}
67         for field in filter:
68             if field in fields:
69                 valid_fields[field] = filter[field]
70         dict.__init__(self, valid_fields)
71
72         # Declare ourselves as a type of parameter that can take
73         # either a value or a list of values for each of the specified
74         # fields.
75         self.fields = dict ( [ ( field, Mixed (expected, [expected])) 
76                                  for (field,expected) in fields.iteritems()
77                                  if python_type(expected) not in (list, tuple, set) ] )
78
79         # Null filter means no filter
80         Parameter.__init__(self, self.fields, doc = doc, nullok = True)
81
82     def quote(self, value):
83         """
84         Returns quoted version of the specified value.
85         """
86
87         # The pgdb._quote function is good enough for general SQL
88         # quoting, except for array types.
89         if isinstance(value, (list, tuple, set)):
90             return "ARRAY[%s]" % ", ".join(map(self.quote, value))
91         else:
92             return pgdb._quote(value)    
93
94     def sql(self, join_with = "AND"):
95         """
96         Returns a SQL conditional that represents this filter.
97         """
98
99         # So that we always return something
100         if join_with == "AND":
101             conditionals = ["True"]
102         elif join_with == "OR":
103             conditionals = ["False"]
104         else:
105             assert join_with in ("AND", "OR")
106
107         # init 
108         sorts = []
109         clips = []
110
111         for field, value in self.iteritems():
112             # handle negation, numeric comparisons
113             # simple, 1-depth only mechanism
114
115             modifiers={'~' : False, 
116                        '<' : False, '>' : False,
117                        '[' : False, ']' : False,
118                        '-' : False,
119                        }
120
121             for char in modifiers.keys():
122                 if field[0] == char:
123                     modifiers[char]=True
124                     field = field[1:]
125                     break
126
127             # filter on fields
128             if not modifiers['-']:
129                 if field not in self.fields:
130                     raise SfaInvalidArgument, "Invalid filter field '%s'" % field
131
132                 if isinstance(value, (list, tuple, set)):
133                     # handling filters like '~slice_id':[]
134                     # this should return true, as it's the opposite of 'slice_id':[] which is false
135                     # prior to this fix, 'slice_id':[] would have returned ``slice_id IN (NULL) '' which is unknown 
136                     # so it worked by coincidence, but the negation '~slice_ids':[] would return false too
137                     if not value:
138                         field=""
139                         operator=""
140                         value = "FALSE"
141                     else:
142                         operator = "IN"
143                         value = map(str, map(self.quote, value))
144                         value = "(%s)" % ", ".join(value)
145                 else:
146                     if value is None:
147                         operator = "IS"
148                         value = "NULL"
149                     elif isinstance(value, StringTypes) and \
150                             (value.find("*") > -1 or value.find("%") > -1):
151                         operator = "LIKE"
152                         # insert *** in pattern instead of either * or %
153                         # we dont use % as requests are likely to %-expansion later on
154                         # actual replacement to % done in PostgreSQL.py
155                         value = value.replace ('*','***')
156                         value = value.replace ('%','***')
157                         value = str(self.quote(value))
158                     else:
159                         operator = "="
160                         if modifiers['<']:
161                             operator='<'
162                         if modifiers['>']:
163                             operator='>'
164                         if modifiers['[']:
165                             operator='<='
166                         if modifiers[']']:
167                             operator='>='
168                         else:
169                             value = str(self.quote(value))
170  
171                 clause = "%s %s %s" % (field, operator, value)
172
173                 if modifiers['~']:
174                     clause = " ( NOT %s ) " % (clause)
175
176                 conditionals.append(clause)
177             # sorting and clipping
178             else:
179                 if field not in ('SORT','OFFSET','LIMIT'):
180                     raise SfaInvalidArgument, "Invalid filter, unknown sort and clip field %r"%field
181                 # sorting
182                 if field == 'SORT':
183                     if not isinstance(value,(list,tuple,set)):
184                         value=[value]
185                     for field in value:
186                         order = 'ASC'
187                         if field[0] == '+':
188                             field = field[1:]
189                         elif field[0] == '-':
190                             field = field[1:]
191                             order = 'DESC'
192                         if field not in self.fields:
193                             raise SfaInvalidArgument, "Invalid field %r in SORT filter"%field
194                         sorts.append("%s %s"%(field,order))
195                 # clipping
196                 elif field == 'OFFSET':
197                     clips.append("OFFSET %d"%value)
198                 # clipping continued
199                 elif field == 'LIMIT' :
200                     clips.append("LIMIT %d"%value)
201
202         where_part = (" %s " % join_with).join(conditionals)
203         clip_part = ""
204         if sorts:
205             clip_part += " ORDER BY " + ",".join(sorts)
206         if clips:
207             clip_part += " " + " ".join(clips)
208         return (where_part,clip_part)