a991f78f266cf6683923c871a083380d07150cc9
[plcapi.git] / PLC / Filter.py
1 #
2 # Thierry Parmentelat - INRIA
3 #
4 from types import StringTypes
5 import time
6
7 from PLC.Faults import *
8 from PLC.Parameter import Parameter, Mixed, python_type
9
10 class Filter(Parameter, dict):
11     """
12     A type of parameter that represents a filter on one or more
13     columns of a database table.
14     Special features provide support for negation, upper and lower bounds,
15     sorting and clipping and more...
16
17
18     fields should be a dictionary of field names and types.
19     As of PLCAPI-4.3-26, we provide support for filtering on
20     sequence types as well, with the special '&' and '|' modifiers.
21     example : fields = {'node_id': Parameter(int, "Node identifier"),
22                         'hostname': Parameter(int, "Fully qualified hostname", max = 255),
23                         ...}
24
25
26     filter should be a dictionary of field names and values
27     representing  the criteria for filtering.
28     example : filter = { 'hostname' : '*.edu' , site_id : [34,54] }
29
30
31     Special features:
32
33     * a field starting with the ~ character means negation.
34     example :  filter = { '~peer_id' : None }
35
36     * a field starting with < [  ] or > means lower than or greater than
37       < > uses strict comparison
38       [ ] is for using <= or >= instead
39     example :  filter = { ']event_id' : 2305 }
40     example :  filter = { '>time' : 1178531418 }
41       in this example the integer value denotes a unix timestamp
42
43     * if a value is a sequence type, then it should represent
44       a list of possible values for that field
45     example : filter = { 'node_id' : [12,34,56] }
46
47     * a (string) value containing either a * or a % character is
48       treated as a (sql) pattern; * are replaced with % that is the
49       SQL wildcard character.
50     example :  filter = { 'hostname' : '*.jp' }
51
52     * a field starting with '&' or '|' should refer to a sequence type
53       the semantics is then that the object value (expected to be a list)
54       should contain all (&) or any (|) value specified in the corresponding
55       filter value. See other examples below.
56     example : filter = { '|role_ids' : [ 20, 40 ] }
57     example : filter = { '|roles' : ['tech', 'pi'] }
58     example : filter = { '&roles' : ['admin', 'tech'] }
59     example : filter = { '&roles' : 'tech' }
60
61     * the filter's keys starting with '-' are special and relate to sorting and clipping
62       * '-SORT' : a field name, or an ordered list of field names that are used for sorting
63         these fields may start with + (default) or - for denoting increasing or decreasing order
64     example : filter = { '-SORT' : [ '+node_id', '-hostname' ] }
65       * '-OFFSET' : the number of first rows to be ommitted
66       * '-LIMIT' : the amount of rows to be returned
67     example : filter = { '-OFFSET' : 100, '-LIMIT':25}
68
69     * similarly the two special keys below allow to change the semantics of multi-keys filters
70       * '-AND' : select rows that match ALL the criteria (default)
71       * '-OR'  : select rows that match ANY criteria
72       The value attached to these keys is ignored. 
73       Please note however that because a Filter is a dict, you cannot provide two criteria on a given key.
74       
75
76     Here are a few realistic examples
77
78     GetNodes ( { 'node_type' : 'regular' , 'hostname' : '*.edu' ,
79                  '-SORT' : 'hostname' , '-OFFSET' : 30 , '-LIMIT' : 25 } )
80       would return regular (usual) nodes matching '*.edu' in alphabetical order from 31th to 55th
81
82     GetNodes ( { '~peer_id' : None } )
83       returns the foreign nodes - that have an integer peer_id
84
85     GetPersons ( { '|role_ids' : [ 20 , 40] } )
86       would return all persons that have either pi (20) or tech (40) roles
87
88     GetPersons ( { '&role_ids' : 10 } )
89     GetPersons ( { '&role_ids' : 10 } )
90     GetPersons ( { '|role_ids' : [ 10 ] } )
91     GetPersons ( { '|role_ids' : [ 10 ] } )
92       all 4 forms are equivalent and would return all admin users in the system
93     """
94
95     debug=False
96 #    debug=True
97
98     def __init__(self, fields = {}, filter = {}, doc = "Attribute filter"):
99         # Store the filter in our dict instance
100         dict.__init__(self, filter)
101
102         # Declare ourselves as a type of parameter that can take
103         # either a value or a list of values for each of the specified
104         # fields.
105         self.fields = dict ( [ ( field, Mixed (expected, [expected]))
106                                  for (field,expected) in fields.iteritems() ] )
107
108         # Null filter means no filter
109         Parameter.__init__(self, self.fields, doc = doc, nullok = True)
110
111     def sql(self, api, join_with = "AND"):
112         """
113         Returns a SQL conditional that represents this filter.
114         """
115
116         if self.has_key('-AND'):
117             del self['-AND']
118             join_with='AND'
119         if self.has_key('-OR'):
120             del self['-OR']
121             join_with='OR'
122
123         self.join_with=join_with
124
125         # So that we always return something
126         if join_with == "AND":
127             conditionals = ["True"]
128         elif join_with == "OR":
129             conditionals = ["False"]
130         else:
131             assert join_with in ("AND", "OR")
132
133         # init
134         sorts = []
135         clips = []
136
137         for field, value in self.iteritems():
138             # handle negation, numeric comparisons
139             # simple, 1-depth only mechanism
140
141             modifiers={'~' : False,
142                        '<' : False, '>' : False,
143                        '[' : False, ']' : False,
144                        '-' : False,
145                        '&' : False, '|' : False,
146                        }
147             def check_modifiers(field):
148                 if field[0] in modifiers.keys():
149                     modifiers[field[0]] = True
150                     field = field[1:]
151                     return check_modifiers(field)
152                 return field
153             field = check_modifiers(field)
154
155             # filter on fields
156             if not modifiers['-']:
157                 if field not in self.fields:
158                     raise PLCInvalidArgument, "Invalid filter field '%s'" % field
159
160                 # handling array fileds always as compound values
161                 if modifiers['&'] or modifiers['|']:
162                     if not isinstance(value, (list, tuple, set)):
163                         value = [value,]
164
165                 def get_op_and_val(value):
166                     if value is None:
167                         operator = "IS"
168                         value = "NULL"
169                     elif isinstance(value, StringTypes) and \
170                             (value.find("*") > -1 or value.find("%") > -1):
171                         operator = "ILIKE"
172                         # insert *** in pattern instead of either * or %
173                         # we dont use % as requests are likely to %-expansion later on
174                         # actual replacement to % done in PostgreSQL.py
175                         value = value.replace ('*','***')
176                         value = value.replace ('%','***')
177                         value = str(api.db.quote(value))
178                     else:
179                         operator = "="
180                         if modifiers['<']:
181                             operator='<'
182                         if modifiers['>']:
183                             operator='>'
184                         if modifiers['[']:
185                             operator='<='
186                         if modifiers[']']:
187                             operator='>='
188                         value = str(api.db.quote(value))
189                     return (operator, value)
190
191                 if isinstance(value, (list, tuple, set)):
192                     # handling filters like '~slice_id':[]
193                     # this should return true, as it's the opposite of 'slice_id':[] which is false
194                     # prior to this fix, 'slice_id':[] would have returned ``slice_id IN (NULL) '' which is unknown
195                     # so it worked by coincidence, but the negation '~slice_ids':[] would return false too
196                     if not value:
197                         if modifiers['&'] or modifiers['|']:
198                             operator = "="
199                             value = "'{}'"
200                         else:
201                             field=""
202                             operator=""
203                             value = "FALSE"
204                         clause = "%s %s %s" % (field, operator, value)
205                     else:
206                         vals = {}
207                         for val in value:
208                             base_op, val = get_op_and_val(val)
209                             if base_op in vals:
210                                 vals[base_op].append(val)
211                             else:
212                                 vals[base_op] = [val]
213                         subclauses = []
214                         for operator in vals.keys():
215                             if operator == '=':
216                                 if modifiers['&']:
217                                     subclauses.append("(%s @> ARRAY[%s])" % (field, ",".join(vals[operator])))
218                                 elif modifiers['|']:
219                                     subclauses.append("(%s && ARRAY[%s])" % (field, ",".join(vals[operator])))
220                                 else:
221                                     subclauses.append("(%s IN (%s))" % (field, ",".join(vals[operator])))
222                             elif operator == 'IS':
223                                 subclauses.append("(%s IS NULL)" % field)
224                             else:
225                                 for value in vals[operator]:
226                                     subclauses.append("(%s %s %s)" % (field, operator, value))
227                         clause = "(" + " OR ".join(subclauses) + ")"
228                 else:
229                     operator, value = get_op_and_val(value)
230
231                     clause = "%s %s %s" % (field, operator, value)
232
233                 if modifiers['~']:
234                     clause = " ( NOT %s ) " % (clause)
235
236                 conditionals.append(clause)
237             # sorting and clipping
238             else:
239                 if field not in ('SORT','OFFSET','LIMIT'):
240                     raise PLCInvalidArgument, "Invalid filter, unknown sort and clip field %r"%field
241                 # sorting
242                 if field == 'SORT':
243                     if not isinstance(value,(list,tuple,set)):
244                         value=[value]
245                     for field in value:
246                         order = 'ASC'
247                         if field[0] == '+':
248                             field = field[1:]
249                         elif field[0] == '-':
250                             field = field[1:]
251                             order = 'DESC'
252                         if field not in self.fields:
253                             raise PLCInvalidArgument, "Invalid field %r in SORT filter"%field
254                         sorts.append("%s %s"%(field,order))
255                 # clipping
256                 elif field == 'OFFSET':
257                     clips.append("OFFSET %d"%value)
258                 # clipping continued
259                 elif field == 'LIMIT' :
260                     clips.append("LIMIT %d"%value)
261
262         where_part = (" %s " % join_with).join(conditionals)
263         clip_part = ""
264         if sorts:
265             clip_part += " ORDER BY " + ",".join(sorts)
266         if clips:
267             clip_part += " " + " ".join(clips)
268         if Filter.debug:
269             print >> log, 'Filter.sql: where_part=',where_part,'clip_part',clip_part
270         return (where_part,clip_part)