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