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