diff --git a/VERSION b/VERSION index 1044843e..23f15a0d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -Version 2.4.1-alpha.2+timestamp.2013.01.08.09.16.57 +Version 2.4.1-alpha.2+timestamp.2013.01.08.09.22.34 diff --git a/gluon/dal.py b/gluon/dal.py index 5cd4c9f7..fbc4c854 100644 --- a/gluon/dal.py +++ b/gluon/dal.py @@ -621,6 +621,7 @@ class BaseAdapter(ConnectionPool): 'boolean': 'CHAR(1)', 'string': 'CHAR(%(length)s)', 'text': 'TEXT', + 'json': 'TEXT', 'password': 'CHAR(%(length)s)', 'blob': 'BLOB', 'upload': 'CHAR(%(length)s)', @@ -1160,8 +1161,8 @@ class BaseAdapter(ConnectionPool): logfile.write('success!\n') def _insert(self, table, fields): - keys = ','.join(f.name for f,v in fields) - values = ','.join(self.expand(v,f.type) for f,v in fields) + keys = ','.join(f.name for f, v in fields) + values = ','.join(self.expand(v, f.type) for f, v in fields) return 'INSERT INTO %s(%s) VALUES (%s);' % (table, keys, values) def insert(self, table, fields): @@ -1225,7 +1226,7 @@ class BaseAdapter(ConnectionPool): self.expand('%'+second, 'string')) def CONTAINS(self, first, second): - if first.type in ('string', 'text'): + if first.type in ('string', 'text', 'json'): key = '%'+str(second).replace('%','%%')+'%' elif first.type.startswith('list:'): key = '%|'+str(second).replace('|','||').replace('%','%%')+'|%' @@ -1719,7 +1720,7 @@ class BaseAdapter(ConnectionPool): obj = obj() if isinstance(fieldtype, SQLCustomType): value = fieldtype.encoder(obj) - if fieldtype.type in ('string','text'): + if fieldtype.type in ('string','text', 'json'): return self.adapt(value) return value if isinstance(obj, (Expression, Field)): @@ -1733,11 +1734,12 @@ class BaseAdapter(ConnectionPool): obj = map(str,obj) else: obj = map(int,obj) - if isinstance(obj, (list, tuple)): + # we don't want to bar_encode json objects + if isinstance(obj, (list, tuple)) and (not fieldtype == "json"): obj = bar_encode(obj) if obj is None: return 'NULL' - if obj == '' and not fieldtype[:2] in ['st', 'te', 'pa', 'up']: + if obj == '' and not fieldtype[:2] in ['st', 'te', 'js', 'pa', 'up']: return 'NULL' r = self.represent_exceptions(obj, fieldtype) if not r is None: @@ -1780,6 +1782,16 @@ class BaseAdapter(ConnectionPool): obj = obj.isoformat()[:10] else: obj = str(obj) + elif (fieldtype == 'json'): + if not isinstance(obj, basestring): + if have_serializers: + obj = serializers.json(obj) + else: + try: + import json as simplejson + except ImportError: + import gluon.contrib.simplejson as simplejson + obj = simplejson.dumps(items) if not isinstance(obj,bytes): obj = bytes(obj) try: @@ -1911,6 +1923,20 @@ class BaseAdapter(ConnectionPool): def parse_double(self, value, field_type): return float(value) + def parse_json(self, value, field_type): + if isinstance(value, basestring): + if isinstance(value, unicode): + value = value.encode('utf-8') + if have_serializers: + value = serializers.loads_json(value) + else: + try: + import json as simplejson + except ImportError: + import gluon.contrib.simplejson as simplejson + value = simplejson.loads(value) + return value + def build_parsemap(self): self.parsemap = { 'id':self.parse_id, @@ -1925,6 +1951,7 @@ class BaseAdapter(ConnectionPool): 'datetime':self.parse_datetime, 'blob':self.parse_blob, 'decimal':self.parse_decimal, + 'json':self.parse_json, 'list:integer':self.parse_list_integers, 'list:reference':self.parse_list_references, 'list:string':self.parse_list_strings, @@ -2307,6 +2334,7 @@ class MySQLAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'LONGTEXT', + 'json': 'LONGTEXT', 'password': 'VARCHAR(%(length)s)', 'blob': 'LONGBLOB', 'upload': 'VARCHAR(%(length)s)', @@ -2419,6 +2447,7 @@ class PostgreSQLAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'TEXT', + 'json': 'TEXT', 'password': 'VARCHAR(%(length)s)', 'blob': 'BYTEA', 'upload': 'VARCHAR(%(length)s)', @@ -2460,7 +2489,7 @@ class PostgreSQLAdapter(BaseAdapter): def ADD(self, first, second): t = first.type - if t in ('text','string','password','upload','blob'): + if t in ('text','string','password', 'json', 'upload','blob'): return '(%s || %s)' % (self.expand(first), self.expand(second, t)) else: return '(%s + %s)' % (self.expand(first), self.expand(second, t)) @@ -2561,7 +2590,7 @@ class PostgreSQLAdapter(BaseAdapter): self.expand('%'+second,'string')) def CONTAINS(self,first,second): - if first.type in ('string','text'): + if first.type in ('string','text', 'json'): key = '%'+str(second).replace('%','%%')+'%' elif first.type.startswith('list:'): key = '%|'+str(second).replace('|','||').replace('%','%%')+'|%' @@ -2666,6 +2695,7 @@ class NewPostgreSQLAdapter(PostgreSQLAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'TEXT', + 'json': 'TEXT', 'password': 'VARCHAR(%(length)s)', 'blob': 'BYTEA', 'upload': 'VARCHAR(%(length)s)', @@ -2765,6 +2795,7 @@ class OracleAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR2(%(length)s)', 'text': 'CLOB', + 'json': 'CLOB', 'password': 'VARCHAR2(%(length)s)', 'blob': 'CLOB', 'upload': 'VARCHAR2(%(length)s)', @@ -2935,6 +2966,7 @@ class MSSQLAdapter(BaseAdapter): 'boolean': 'BIT', 'string': 'VARCHAR(%(length)s)', 'text': 'TEXT', + 'json': 'TEXT', 'password': 'VARCHAR(%(length)s)', 'blob': 'IMAGE', 'upload': 'VARCHAR(%(length)s)', @@ -3148,6 +3180,7 @@ class MSSQL2Adapter(MSSQLAdapter): 'boolean': 'CHAR(1)', 'string': 'NVARCHAR(%(length)s)', 'text': 'NTEXT', + 'json': 'NTEXT', 'password': 'NVARCHAR(%(length)s)', 'blob': 'IMAGE', 'upload': 'NVARCHAR(%(length)s)', @@ -3172,7 +3205,7 @@ class MSSQL2Adapter(MSSQLAdapter): def represent(self, obj, fieldtype): value = BaseAdapter.represent(self, obj, fieldtype) - if fieldtype in ('string','text') and value[:1]=="'": + if fieldtype in ('string','text', 'json') and value[:1]=="'": value = 'N'+value return value @@ -3186,6 +3219,7 @@ class SybaseAdapter(MSSQLAdapter): 'boolean': 'BIT', 'string': 'CHAR VARYING(%(length)s)', 'text': 'TEXT', + 'json': 'TEXT', 'password': 'CHAR VARYING(%(length)s)', 'blob': 'IMAGE', 'upload': 'CHAR VARYING(%(length)s)', @@ -3280,6 +3314,7 @@ class FireBirdAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'BLOB SUB_TYPE 1', + 'json': 'BLOB SUB_TYPE 1', 'password': 'VARCHAR(%(length)s)', 'blob': 'BLOB SUB_TYPE 0', 'upload': 'VARCHAR(%(length)s)', @@ -3448,6 +3483,7 @@ class InformixAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'BLOB SUB_TYPE 1', + 'json': 'BLOB SUB_TYPE 1', 'password': 'VARCHAR(%(length)s)', 'blob': 'BLOB SUB_TYPE 0', 'upload': 'VARCHAR(%(length)s)', @@ -3575,6 +3611,7 @@ class DB2Adapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'CLOB', + 'json': 'CLOB', 'password': 'VARCHAR(%(length)s)', 'blob': 'BLOB', 'upload': 'VARCHAR(%(length)s)', @@ -3660,6 +3697,7 @@ class TeradataAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'CLOB', + 'json': 'CLOB', 'password': 'VARCHAR(%(length)s)', 'blob': 'BLOB', 'upload': 'VARCHAR(%(length)s)', @@ -3726,6 +3764,7 @@ class IngresAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'CLOB', + 'json': 'CLOB', 'password': 'VARCHAR(%(length)s)', ## Not sure what this contains utf8 or nvarchar. Or even bytes? 'blob': 'BLOB', 'upload': 'VARCHAR(%(length)s)', ## FIXME utf8 or nvarchar... or blob? what is this type? @@ -3828,6 +3867,7 @@ class IngresUnicodeAdapter(IngresAdapter): 'boolean': 'CHAR(1)', 'string': 'NVARCHAR(%(length)s)', 'text': 'NCLOB', + 'json': 'NCLOB', 'password': 'NVARCHAR(%(length)s)', ## Not sure what this contains utf8 or nvarchar. Or even bytes? 'blob': 'BLOB', 'upload': 'VARCHAR(%(length)s)', ## FIXME utf8 or nvarchar... or blob? what is this type? @@ -3858,6 +3898,7 @@ class SAPDBAdapter(BaseAdapter): 'boolean': 'CHAR(1)', 'string': 'VARCHAR(%(length)s)', 'text': 'LONG', + 'json': 'LONG', 'password': 'VARCHAR(%(length)s)', 'blob': 'LONG', 'upload': 'VARCHAR(%(length)s)', @@ -4159,7 +4200,7 @@ class NoSQLAdapter(BaseAdapter): if not isinstance(obj, (list, tuple)): obj = [obj] if obj == '' and not \ - (is_string and fieldtype[:2] in ['st','te','pa','up']): + (is_string and fieldtype[:2] in ['st','te', 'pa','up']): return None if not obj is None: if isinstance(obj, list) and not is_list: @@ -4202,6 +4243,17 @@ class NoSQLAdapter(BaseAdapter): obj = datetime.datetime(y, m, d, h, mi, s) elif fieldtype == 'blob': pass + elif fieldtype == 'json': + if isinstance(obj, basestring): + obj = self.to_unicode(obj) + if have_serializers: + obj = serializers.loads_json(obj) + else: + try: + import json as simplejson + except ImportError: + import gluon.contrib.simplejson as simplejson + obj = serializers.loads_json(obj) elif is_string and field_is_type('list:string'): return map(self.to_unicode,obj) elif is_list: @@ -4309,6 +4361,7 @@ class GoogleDatastoreAdapter(NoSQLAdapter): 'boolean': gae.BooleanProperty, 'string': (lambda: gae.StringProperty(multiline=True)), 'text': gae.TextProperty, + 'json': gae.TextProperty, 'password': gae.StringProperty, 'blob': gae.BlobProperty, 'upload': gae.StringProperty, @@ -4388,7 +4441,7 @@ class GoogleDatastoreAdapter(NoSQLAdapter): def expand(self,expression,field_type=None): if isinstance(expression,Field): - if expression.type in ('text','blob'): + if expression.type in ('text', 'blob', 'json'): raise SyntaxError('AppEngine does not index by: %s' % expression.type) return expression.name elif isinstance(expression, (Expression, Query)): @@ -4527,7 +4580,7 @@ class GoogleDatastoreAdapter(NoSQLAdapter): elif args_get('projection') == True: projection = [] for f in fields: - if f.type in ['text', 'blob']: + if f.type in ['text', 'blob', 'json']: raise SyntaxError( "text and blob field types not allowed in projection queries") else: @@ -4708,6 +4761,7 @@ class CouchDBAdapter(NoSQLAdapter): 'boolean': bool, 'string': str, 'text': str, + 'json': str, 'password': str, 'blob': str, 'upload': str, @@ -4907,6 +4961,7 @@ class MongoDBAdapter(NoSQLAdapter): 'boolean': bool, 'string': str, 'text': str, + 'json': str, 'password': str, 'blob': str, 'upload': str, @@ -4918,78 +4973,65 @@ class MongoDBAdapter(NoSQLAdapter): 'time': datetime.time, 'datetime': datetime.datetime, 'id': long, - 'mongo': unicode, # any Mongodb document (not implemented) 'reference': long, 'list:string': list, 'list:integer': list, 'list:reference': list, } + error_messages = {"javascript_needed": "This must yet be replaced" + + " with javascript in order to work."} + def __init__(self,db,uri='mongodb://127.0.0.1:5984/db', - pool_size=0,folder=None,db_codec ='UTF-8', + pool_size=0, folder=None, db_codec ='UTF-8', credential_decoder=IDENTITY, driver_args={}, adapter_args={}, do_connect=True): + self.db = db self.uri = uri if do_connect: self.find_driver(adapter_args) - - m=None - import random - try: - from pymongo.objectid import ObjectId - except ImportError: - from bson.objectid import ObjectId - try: - from bson.son import SON - except ImportError: - from pymongo.son import SON + from bson.objectid import ObjectId + from bson.son import SON + import pymongo.uri_parser + + m = pymongo.uri_parser.parse_uri(uri) self.SON = SON self.ObjectId = ObjectId self.random = random - try: - #Since version 2 - import pymongo.uri_parser - m = pymongo.uri_parser.parse_uri(uri) - except ImportError: - try: - #before version 2 of pymongo - import pymongo.connection - m = pymongo.connection._parse_uri(uri) - except ImportError: - raise ImportError("Uriparser for mongodb is not available") - except: - raise SyntaxError("This type of uri is not supported by the mongodb uri parser") self.dbengine = 'mongodb' self.folder = folder db['_lastsql'] = '' self.db_codec = 'UTF-8' self.pool_size = pool_size - #this is the minimum amount of replicates that it should wait for on insert/update + #this is the minimum amount of replicates that it should wait + # for on insert/update self.minimumreplication = adapter_args.get('minimumreplication',0) - #by default alle insert and selects are performand asynchronous, but now the default is - #synchronous, except when overruled by either this default or function parameter + # by default all inserts and selects are performand asynchronous, + # but now the default is + # synchronous, except when overruled by either this default or + # function parameter self.safe = adapter_args.get('safe',True) - if isinstance(m,tuple): m = {"database" : m[1]} if m.get('database')==None: raise SyntaxError("Database is required!") def connector(uri=self.uri,m=m): try: - return self.driver.Connection(uri)[m.get('database')] + # Connection() is deprecated + if hasattr(self.driver, "MongoClient"): + Connection = self.driver.MongoClient + else: + Connection = self.driver.Connection + return Connection(uri)[m.get('database')] except self.driver.errors.ConnectionFailure: inst = sys.exc_info()[1] - raise SyntaxError("The connection to " + uri + " could not be made") - except Exception: - inst = sys.exc_info()[1] - if inst == "cannot specify database without a username and password": - raise SyntaxError("You are probebly running version 1.1 of pymongo which contains a bug which requires authentication. Update your pymongo.") - else: - raise SyntaxError("This is not an official Mongodb uri (http://www.mongodb.org/display/DOCS/Connections) Error : %s" % inst) + raise SyntaxError("The connection to " + + uri + " could not be made") + self.reconnect(connector,cursor=False) def object_id(self, arg=None): @@ -5005,21 +5047,24 @@ class MongoDBAdapter(NoSQLAdapter): arg = int(arg) elif arg == "": arg = int("0x%sL" % \ - str("".join([self.random.choice("0123456789abcdef") for x in \ - range(24)])), 0) + "".join([self.random.choice("0123456789abcdef") \ + for x in range(24)]), 0) elif arg.isalnum(): if not arg.startswith("0x"): arg = "0x%s" % arg try: arg = int(arg, 0) except ValueError, e: - raise ValueError("invalid objectid argument string: %s" % e) + raise ValueError( + "invalid objectid argument string: %s" % e) else: - raise ValueError("invalid objectid argument string. requires an integer or base 16 value") + raise ValueError("Invalid objectid argument string. " + + "Requires an integer or base 16 value") elif isinstance(arg, self.ObjectId): return arg if not isinstance(arg, (int, long)): - raise TypeError("object_id argument must be of type ObjectId or an objectid representable integer") + raise TypeError("object_id argument must be of type " + + "ObjectId or an objectid representable integer") if arg == 0: hexvalue = "".zfill(24) else: @@ -5031,60 +5076,66 @@ class MongoDBAdapter(NoSQLAdapter): if fieldtype =='date': if value == None: return value - t = datetime.time(0, 0, 0)#this piece of data can be stripped off based on the fieldtype - return datetime.datetime.combine(value, t) #mongodb doesn't has a date object and so it must datetime, string or integer + # this piece of data can be stripped off based on the fieldtype + t = datetime.time(0, 0, 0) + # mongodb doesn't has a date object and so it must datetime, + # string or integer + return datetime.datetime.combine(value, t) elif fieldtype == 'time': if value == None: return value - d = datetime.date(2000, 1, 1) #this piece of data can be stripped of based on the fieldtype - return datetime.datetime.combine(d, value) #mongodb doesn't has a time object and so it must datetime, string or integer - elif fieldtype == 'list:string' or fieldtype == 'list:integer' or fieldtype == 'list:reference': - return value #raise SyntaxError("Not Supported") + # this piece of data can be stripped of based on the fieldtype + d = datetime.date(2000, 1, 1) + # mongodb doesn't has a time object and so it must datetime, + # string or integer + return datetime.datetime.combine(d, value) + elif fieldtype == 'list:string' or \ + fieldtype == 'list:integer' or \ + fieldtype == 'list:reference': + return value return value - #Safe determines whether a asynchronious request is done or a synchronious action is done - #For safety, we use by default synchronious requests - def insert(self,table,fields,safe=None): + # Safe determines whether a asynchronious request is done or a + # synchronious action is done + # For safety, we use by default synchronious requests + def insert(self, table, fields, safe=None): if safe==None: - safe=self.safe + safe = self.safe ctable = self.connection[table._tablename] values = dict() for k, v in fields: - # avoid writing "id" name reserved form Mongodb - if not k.name == "id": + if not k.name in ["id", "safe"]: fieldname = k.name fieldtype = table[k.name].type if ("reference" in fieldtype) or (fieldtype=="id"): values[fieldname] = self.object_id(v) else: values[fieldname] = self.represent(v, fieldtype) - ctable.insert(values,safe=safe) + ctable.insert(values, safe=safe) return int(str(values['_id']), 16) - def create_table(self, table, migrate=True, fake_migrate=False, polymodel=None, isCapped=False): + def create_table(self, table, migrate=True, fake_migrate=False, + polymodel=None, isCapped=False): if isCapped: raise RuntimeError("Not implemented") - else: - pass - def count(self,query,distinct=None,snapshot=True): + def count(self, query, distinct=None, snapshot=True): if distinct: raise RuntimeError("COUNT DISTINCT not supported") if not isinstance(query,Query): raise SyntaxError("Not Supported") tablename = self.get_table(query) - return int(self.select(query,[self.db[tablename]._id],{},count=True,snapshot=snapshot)['count']) - #Maybe it would be faster if we just implemented the pymongo .count() function which is probably quicker? - # therefor call __select() connection[table].find(query).count() Since this will probably reduce the return set? + return int(self.select(query,[self.db[tablename]._id], {}, + count=True,snapshot=snapshot)['count']) + # Maybe it would be faster if we just implemented the pymongo + # .count() function which is probably quicker? + # therefor call __select() connection[table].find(query).count() + # Since this will probably reduce the return set? def expand(self, expression, field_type=None): - #if isinstance(expression,Field): - # if expression.type=='id': - # return {_id}" - result = None if isinstance(expression, Query): # any query using 'id':= - # set name as _id (as per pymongo/mongodb primary key) + # set name as _id (as per pymongo/mongodb primary key) # convert second arg to an objectid field # (if its not already) # if second arg is 0 convert to objectid @@ -5101,7 +5152,7 @@ class MongoDBAdapter(NoSQLAdapter): result = "_id" else: result = expression.name - #return expression + elif isinstance(expression, (Expression, Query)): if not expression.second is None: result = expression.op(expression.first, expression.second) @@ -5114,15 +5165,17 @@ class MongoDBAdapter(NoSQLAdapter): elif field_type: result = str(self.represent(expression,field_type)) elif isinstance(expression,(list,tuple)): - result = ','.join(self.represent(item,field_type) for item in expression) + result = ','.join(self.represent(item,field_type) for + item in expression) else: result = expression return result - def _select(self,query,fields,attributes): + def _select(self, query, fields, attributes): if 'for_update' in attributes: logging.warn('mongodb does not support for_update') - for key in set(attributes.keys())-set(('limitby','orderby','for_update')): + for key in set(attributes.keys())-set(('limitby', + 'orderby','for_update')): if attributes[key]!=None: logging.warn('select attribute not implemented: %s' % key) @@ -5132,18 +5185,17 @@ class MongoDBAdapter(NoSQLAdapter): # try an orderby attribute orderby = attributes.get('orderby', False) limitby = attributes.get('limitby', False) - #distinct = attributes.get('distinct', False) + # distinct = attributes.get('distinct', False) if orderby: - #print "in if orderby %s" % orderby if isinstance(orderby, (list, tuple)): orderby = xorify(orderby) # !!!! need to add 'random' for f in self.expand(orderby).split(','): if f.startswith('-'): - mongosort_list.append((f[1:],-1)) + mongosort_list.append((f[1:], -1)) else: - mongosort_list.append((f,1)) + mongosort_list.append((f, 1)) if limitby: limitby_skip, limitby_limit = limitby @@ -5153,7 +5205,7 @@ class MongoDBAdapter(NoSQLAdapter): mongofields_dict = self.SON() mongoqry_dict = {} for item in fields: - if isinstance(item,SQLALL): + if isinstance(item, SQLALL): new_fields += item._table else: new_fields.append(item) @@ -5163,64 +5215,73 @@ class MongoDBAdapter(NoSQLAdapter): elif len(fields) != 0: tablename = fields[0].tablename else: - raise SyntaxError("The table name could not be found in the query nor from the select statement.") + raise SyntaxError("The table name could not be found in " + + "the query nor from the select statement.") + mongoqry_dict = self.expand(query) fields = fields or self.db[tablename] for field in fields: mongofields_dict[field.name] = 1 - return tablename, mongoqry_dict, mongofields_dict, \ - mongosort_list, limitby_limit, limitby_skip - # need to define all the 'sql' methods gt,lt etc.... + return tablename, mongoqry_dict, mongofields_dict, mongosort_list, \ + limitby_limit, limitby_skip - def select(self,query,fields,attributes,count=False,snapshot=False): - tablename, mongoqry_dict, mongofields_dict, \ - mongosort_list, limitby_limit, limitby_skip = \ - self._select(query,fields,attributes) + def select(self, query, fields, attributes, count=False, + snapshot=False): + # TODO: support joins + tablename, mongoqry_dict, mongofields_dict, mongosort_list, \ + limitby_limit, limitby_skip = self._select(query, fields, attributes) ctable = self.connection[tablename] + if count: return {'count' : ctable.find( mongoqry_dict, mongofields_dict, skip=limitby_skip, limit=limitby_limit, sort=mongosort_list, snapshot=snapshot).count()} else: - mongo_list_dicts = ctable.find( - mongoqry_dict, mongofields_dict, - skip=limitby_skip, limit=limitby_limit, - sort=mongosort_list, snapshot=snapshot) # pymongo cursor object - + # pymongo cursor object + mongo_list_dicts = ctable.find(mongoqry_dict, + mongofields_dict, skip=limitby_skip, + limit=limitby_limit, sort=mongosort_list, + snapshot=snapshot) rows = [] - ### populate row in proper order - colnames = [str(field) for field in fields] - # for k,record in enumerate(mongo_list_dicts): + # populate row in proper order + # Here we replace ._id with .id to follow the standard naming + colnames = [] + newnames = [] + for field in fields: + colname = str(field) + colnames.append(colname) + tablename, fieldname = colname.split(".") + if fieldname == "_id": + # Mongodb reserved uuid key + field.name = "id" + newnames.append(".".join((tablename, field.name))) + for record in mongo_list_dicts: row=[] - for fullcolname in colnames: - colname = fullcolname.split('.')[1] - column = '_id' if colname=='id' else colname - if column in record: - # if column in ('_id', "id") and isinstance( - # record[column], self.ObjectId): - if isinstance(record[column], self.ObjectId): - value = int(str(record[column]), 16) - elif column != '_id': - value = record[column] + for colname in colnames: + tablename, fieldname = colname.split(".") + # switch to Mongo _id uuids for retrieving + # record id's + if fieldname == "id": fieldname = "_id" + if fieldname in record: + if isinstance(record[fieldname], + self.ObjectId): + value = int(str(record[fieldname]), 16) else: - value = None + value = record[fieldname] else: value = None row.append(value) rows.append(row) - processor = attributes.get('processor',self.parse) - result = processor(rows,fields,colnames,False) - # we need to point .id to ._id for scaffolding actions - for row in result: - if hasattr(row, "_id"): - row.id = row._id + processor = attributes.get('processor', self.parse) + result = processor(rows, fields, newnames, False) return result - def INVERT(self,first): + + def INVERT(self, first): #print "in invert first=%s" % first return '-%s' % self.expand(first) @@ -5229,37 +5290,31 @@ class MongoDBAdapter(NoSQLAdapter): ctable.drop() - def truncate(self,table,mode,safe=None): - if safe==None: + def truncate(self, table, mode, safe=None): + if safe == None: safe=self.safe ctable = self.connection[table._tablename] ctable.remove(None, safe=True) - #the update function should return a string - def oupdate(self,tablename,query,fields): - if not isinstance(query,Query): + def oupdate(self, tablename, query, fields): + if not isinstance(query, Query): raise SyntaxError("Not Supported") filter = None if query: filter = self.expand(query) - f_v = [] + modify = {'$set': dict((k.name, self.represent(v, k.type)) for + k, v in fields)} + return modify, filter - modify = { '$set' : dict(((k.name,self.represent(v,k.type)) for k,v in fields)) } - return modify,filter - - # TODO implement set operator - # TODO implement find and modify - # TODO implement complex update - - def update(self,tablename,query,fields,safe=None): - if safe==None: - safe=self.safe - #return amount of adjusted rows or zero, but no exceptions + def update(self, tablename, query, fields, safe=None): + if safe == None: + safe = self.safe + # return amount of adjusted rows or zero, but no exceptions # @ related not finding the result - if not isinstance(query,Query): + if not isinstance(query, Query): raise RuntimeError("Not implemented") - amount = self.count(query,False) - modify,filter = self.oupdate(tablename,query,fields) + amount = self.count(query, False) + modify, filter = self.oupdate(tablename, query, fields) try: result = self.connection[tablename].update(filter, modify, multi=True, safe=safe) @@ -5272,26 +5327,21 @@ class MongoDBAdapter(NoSQLAdapter): else: return amount except Exception, e: - #TODO Reverse update query to verifiy that the query succeded + # TODO Reverse update query to verifiy that the query succeded raise RuntimeError("uncaught exception when updating rows: %s" % e) - """ - (NOTE: missing method for this docstring) - An special update operator that enables the update of specific field - return a dict - """ - #this function returns a dict with the where clause and update fields def _update(self,tablename,query,fields): - return str(self.oupdate(tablename,query,fields)) + return str(self.oupdate(tablename, query, fields)) def delete(self, tablename, query, safe=None): if safe is None: safe = self.safe amount = 0 - amount = self.count(query,False) + amount = self.count(query, False) if not isinstance(query, Query): - raise RuntimeError("query type %s is not supported" % type(query)) + raise RuntimeError("query type %s is not supported" % \ + type(query)) filter = self.expand(query) self._delete(tablename, filter, safe=safe) return amount @@ -5302,7 +5352,7 @@ class MongoDBAdapter(NoSQLAdapter): def bulk_insert(self, table, items): return [self.insert(table,item) for item in items] - #TODO This will probably not work:( + # TODO This will probably not work:( def NOT(self, first): result = {} result["$not"] = self.expand(first) @@ -5315,7 +5365,7 @@ class MongoDBAdapter(NoSQLAdapter): return f def OR(self,first,second): - # pymongo expects: .find( {'$or' : [{'name':'1'}, {'name':'2'}] } ) + # pymongo expects: .find({'$or': [{'name':'1'}, {'name':'2'}]}) result = {} f = self.expand(first) s = self.expand(second) @@ -5332,9 +5382,6 @@ class MongoDBAdapter(NoSQLAdapter): def EQ(self,first,second): result = {} - #if second is None: - #return '(%s == null)' % self.expand(first) - #return '(%s == %s)' % (self.expand(first),self.expand(second,first.type)) result[self.expand(first)] = self.expand(second) return result @@ -5370,84 +5417,101 @@ class MongoDBAdapter(NoSQLAdapter): return result def ADD(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") - return '%s + %s' % (self.expand(first), self.expand(second, first.type)) + raise NotImplementedError(self.error_messages["javascript_needed"]) + return '%s + %s' % (self.expand(first), + self.expand(second, first.type)) def SUB(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") - return '(%s - %s)' % (self.expand(first), self.expand(second, first.type)) + raise NotImplementedError(self.error_messages["javascript_needed"]) + return '(%s - %s)' % (self.expand(first), + self.expand(second, first.type)) def MUL(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") - return '(%s * %s)' % (self.expand(first), self.expand(second, first.type)) + raise NotImplementedError(self.error_messages["javascript_needed"]) + return '(%s * %s)' % (self.expand(first), + self.expand(second, first.type)) def DIV(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") - return '(%s / %s)' % (self.expand(first), self.expand(second, first.type)) + raise NotImplementedError(self.error_messages["javascript_needed"]) + return '(%s / %s)' % (self.expand(first), + self.expand(second, first.type)) def MOD(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") - return '(%s %% %s)' % (self.expand(first), self.expand(second, first.type)) + raise NotImplementedError(self.error_messages["javascript_needed"]) + return '(%s %% %s)' % (self.expand(first), + self.expand(second, first.type)) def AS(self, first, second): - raise NotImplementedError("This must yet be replaced with javascript in order to accomplish this. Sorry") + raise NotImplementedError(self.error_messages["javascript_needed"]) return '%s AS %s' % (self.expand(first), second) - #We could implement an option that simulates a full featured SQL database. But I think the option should be set explicit or implemented as another library. + # We could implement an option that simulates a full featured SQL + # database. But I think the option should be set explicit or + # implemented as another library. def ON(self, first, second): - raise NotImplementedError("This is not possible in NoSQL, but can be simulated with a wrapper.") + raise NotImplementedError("This is not possible in NoSQL" + + " but can be simulated with a wrapper.") return '%s ON %s' % (self.expand(first), self.expand(second)) - # # BLOW ARE TWO IMPLEMENTATIONS OF THE SAME FUNCITONS # WHICH ONE IS BEST? - # def COMMA(self, first, second): return '%s, %s' % (self.expand(first), self.expand(second)) def LIKE(self, first, second): #escaping regex operators? - return {self.expand(first) : ('%s' % self.expand(second, 'string').replace('%','/'))} + return {self.expand(first): ('%s' % \ + self.expand(second, 'string').replace('%','/'))} def STARTSWITH(self, first, second): #escaping regex operators? - return {self.expand(first) : ('/^%s/' % self.expand(second, 'string'))} + return {self.expand(first): ('/^%s/' % \ + self.expand(second, 'string'))} def ENDSWITH(self, first, second): #escaping regex operators? - return {self.expand(first) : ('/%s^/' % self.expand(second, 'string'))} + return {self.expand(first): ('/%s^/' % \ + self.expand(second, 'string'))} def CONTAINS(self, first, second): - #There is a technical difference, but mongodb doesn't support that, but the result will be the same - return {self.expand(first) : ('/%s/' % self.expand(second, 'string'))} + #There is a technical difference, but mongodb doesn't support + # that, but the result will be the same + return {self.expand(first) : ('/%s/' % \ + self.expand(second, 'string'))} def LIKE(self, first, second): import re - return {self.expand(first) : {'$regex' : re.escape(self.expand(second, 'string')).replace('%','.*')}} + return {self.expand(first): {'$regex': \ + re.escape(self.expand(second, + 'string')).replace('%','.*')}} #TODO verify full compatibilty with official SQL Like operator def STARTSWITH(self, first, second): #TODO Solve almost the same problem as with endswith import re - return {self.expand(first) : {'$regex' : '^' + re.escape(self.expand(second, 'string'))}} + return {self.expand(first): {'$regex' : '^' + + re.escape(self.expand(second, + 'string'))}} #TODO verify full compatibilty with official SQL Like operator def ENDSWITH(self, first, second): #escaping regex operators? - #TODO if searched for a name like zsa_corbitt and the function is endswith('a') then this is also returned. Aldo it end with a t + #TODO if searched for a name like zsa_corbitt and the function + # is endswith('a') then this is also returned. + # Aldo it end with a t import re - return {self.expand(first) : {'$regex' : re.escape(self.expand(second, 'string')) + '$'}} + return {self.expand(first): {'$regex': \ + re.escape(self.expand(second, 'string')) + '$'}} #TODO verify full compatibilty with official oracle contains operator def CONTAINS(self, first, second): - #There is a technical difference, but mongodb doesn't support that, but the result will be the same + #There is a technical difference, but mongodb doesn't support + # that, but the result will be the same #TODO contains operators need to be transformed to Regex - return {self.expand(first) : {' $regex' : ".*" + re.escape(self.expand(second, 'string')) + ".*"}} + return {self.expand(first) : {' $regex': \ + ".*" + re.escape(self.expand(second, 'string')) + ".*"}} - # - # END REDUNDANCY - # class IMAPAdapter(NoSQLAdapter): drivers = ('imaplib',) @@ -5880,17 +5944,26 @@ class IMAPAdapter(NoSQLAdapter): def create_table(self, *args, **kwargs): # not implemented - LOGGER.debug("Create table feature is not implemented for %s" % type(self)) + # but required by DAL + pass - def _select(self,query,fields,attributes): + def _select(self, query, fields, attributes): + if use_common_filters(query): + query = self.common_filter(query, [self.get_query_mailbox(query),]) + return str(query) + + def select(self,query,fields,attributes): """ Search and Fetch records and return web2py rows """ + # tablename, imapqry_array , fieldnames = self._select(query,fields,attributes) + ######################################## + ############# Start new .select() ###### + # move this statement elsewhere (upper-level) if use_common_filters(query): query = self.common_filter(query, [self.get_query_mailbox(query),]) - # move this statement elsewhere (upper-level) import email import email.header decode_header = email.header.decode_header @@ -5898,68 +5971,62 @@ class IMAPAdapter(NoSQLAdapter): # convert results to a dictionary tablename = None fetch_results = list() - if isinstance(query, (Expression, Query)): + if isinstance(query, Query): tablename = self.get_table(query) mailbox = self.connection.mailbox_names.get(tablename, None) - if isinstance(query, Expression): - pass - elif isinstance(query, Query): - if mailbox is not None: - # select with readonly - selected = self.connection.select(mailbox, True) - self.mailbox_size = int(selected[1][0]) - search_query = "(%s)" % str(query).strip() - search_result = self.connection.uid("search", None, search_query) - # Normal IMAP response OK is assumed (change this) - if search_result[0] == "OK": - # For "light" remote server responses just get the first - # ten records (change for non-experimental implementation) - # However, light responses are not guaranteed with this - # approach, just fewer messages. - # TODO: change limitby single to 2-tuple argument - limitby = attributes.get('limitby', None) - messages_set = search_result[1][0].split() - # descending order - messages_set.reverse() - if limitby is not None: - # TODO: asc/desc attributes - messages_set = messages_set[int(limitby[0]):int(limitby[1])] - # Partial fetches are not used since the email - # library does not seem to support it (it converts - # partial messages to mangled message instances) - imap_fields = "(RFC822)" - if len(messages_set) > 0: - # create fetch results object list - # fetch each remote message and store it in memmory - # (change to multi-fetch command syntax for faster - # transactions) - for uid in messages_set: - # fetch the RFC822 message body - typ, data = self.connection.uid("fetch", uid, imap_fields) - if typ == "OK": - fr = {"message": int(data[0][0].split()[0]), - "uid": int(uid), - "email": email.message_from_string(data[0][1]), - "raw_message": data[0][1] - } - fr["multipart"] = fr["email"].is_multipart() - # fetch flags for the message - ftyp, fdata = self.connection.uid("fetch", uid, "(FLAGS)") - if ftyp == "OK": - fr["flags"] = self.driver.ParseFlags(fdata[0]) - fetch_results.append(fr) - else: - # error retrieving the flags for this message - pass + if mailbox is not None: + # select with readonly + selected = self.connection.select(mailbox, True) + self.mailbox_size = int(selected[1][0]) + search_query = "(%s)" % str(query).strip() + search_result = self.connection.uid("search", None, search_query) + # Normal IMAP response OK is assumed (change this) + if search_result[0] == "OK": + # For "light" remote server responses just get the first + # ten records (change for non-experimental implementation) + # However, light responses are not guaranteed with this + # approach, just fewer messages. + # TODO: change limitby single to 2-tuple argument + limitby = attributes.get('limitby', None) + messages_set = search_result[1][0].split() + # descending order + messages_set.reverse() + if limitby is not None: + # TODO: asc/desc attributes + messages_set = messages_set[int(limitby[0]):int(limitby[1])] + # Partial fetches are not used since the email + # library does not seem to support it (it converts + # partial messages to mangled message instances) + imap_fields = "(RFC822)" + if len(messages_set) > 0: + # create fetch results object list + # fetch each remote message and store it in memmory + # (change to multi-fetch command syntax for faster + # transactions) + for uid in messages_set: + # fetch the RFC822 message body + typ, data = self.connection.uid("fetch", uid, imap_fields) + if typ == "OK": + fr = {"message": int(data[0][0].split()[0]), + "uid": int(uid), + "email": email.message_from_string(data[0][1]), + "raw_message": data[0][1]} + fr["multipart"] = fr["email"].is_multipart() + # fetch flags for the message + ftyp, fdata = self.connection.uid("fetch", uid, "(FLAGS)") + if ftyp == "OK": + fr["flags"] = self.driver.ParseFlags(fdata[0]) + fetch_results.append(fr) else: - # error retrieving the message body + # error retrieving the flags for this message pass - - elif isinstance(query, basestring): - # not implemented - pass + else: + # error retrieving the message body + pass + elif isinstance(query, (Expression, basestring)): + raise NotImplementedError() else: - pass + raise TypeError("Unexpected query type") imapqry_dict = {} imapfields_dict = {} @@ -6047,6 +6114,7 @@ class IMAPAdapter(NoSQLAdapter): item_dict["%s.answered" % tablename] = "\\Answered" in flags if "%s.mime" % tablename in fieldnames: item_dict["%s.mime" % tablename] = message.get_content_type() + # Here goes the whole RFC822 body as an email instance # for controller side custom processing # The message is stored as a raw string @@ -6054,6 +6122,7 @@ class IMAPAdapter(NoSQLAdapter): # returns a Message object for enhanced object processing if "%s.email" % tablename in fieldnames: item_dict["%s.email" % tablename] = self.encode_text(raw_message, charset) + # Size measure as suggested in a Velocity Reviews post # by Tim Williams: "how to get size of email attachment" # Note: len() and server RFC822.SIZE reports doesn't match @@ -6070,11 +6139,9 @@ class IMAPAdapter(NoSQLAdapter): if "%s.size" % tablename in fieldnames: if part is not None: size += len(str(part)) - item_dict["%s.content" % tablename] = bar_encode(content) item_dict["%s.attachments" % tablename] = bar_encode(attachments) item_dict["%s.size" % tablename] = size - imapqry_list.append(item_dict) # extra object mapping for the sake of rows object @@ -6085,23 +6152,22 @@ class IMAPAdapter(NoSQLAdapter): imapqry_array_item.append(item_dict[fieldname]) imapqry_array.append(imapqry_array_item) - return tablename, imapqry_array, fieldnames + # return tablename, imapqry_array, fieldnames + ############# End new .select() ######## + ######################################## - def select(self,query,fields,attributes): - tablename, imapqry_array , fieldnames = self._select(query,fields,attributes) # parse result and return a rows object colnames = fieldnames processor = attributes.get('processor',self.parse) return processor(imapqry_array, fields, colnames) - def update(self, tablename, query, fields): + def _update(self, tablename, query, fields, commit=False): + # TODO: the adapter should implement an .expand method + commands = list() if use_common_filters(query): query = self.common_filter(query, [tablename,]) - mark = [] unmark = [] - rowcount = 0 - query = str(query) if query: for item in fields: field = item[0] @@ -6114,26 +6180,33 @@ class IMAPAdapter(NoSQLAdapter): mark.append(flag) else: unmark.append(flag) - result, data = self.connection.select( self.connection.mailbox_names[tablename]) string_query = "(%s)" % query result, data = self.connection.search(None, string_query) store_list = [item.strip() for item in data[0].split() if item.strip().isdigit()] - # change marked flags + # build commands for marked flags for number in store_list: result = None if len(mark) > 0: - result, data = self.connection.store( - number, "+FLAGS", "(%s)" % " ".join(mark)) + commands.append((number, "+FLAGS", "(%s)" % " ".join(mark))) if len(unmark) > 0: - result, data = self.connection.store( - number, "-FLAGS", "(%s)" % " ".join(unmark)) - if result == "OK": - rowcount += 1 + commands.append((number, "-FLAGS", "(%s)" % " ".join(unmark))) + return commands + + def update(self, tablename, query, fields): + rowcount = 0 + commands = self._update(tablename, query, fields) + for command in commands: + result, data = self.connection.store(*command) + if result == "OK": + rowcount += 1 return rowcount + def _count(self, query, distinct=None): + raise NotImplementedError() + def count(self,query,distinct=None): counter = 0 tablename = self.get_query_mailbox(query) @@ -6409,12 +6482,10 @@ def sqlhtml_validators(field): return r._format(row) else: return id - if field_type == 'string': - requires.append(validators.IS_LENGTH(field_length)) - elif field_type == 'text': - requires.append(validators.IS_LENGTH(field_length)) - elif field_type == 'password': + if field_type in (('string', 'text', 'password')): requires.append(validators.IS_LENGTH(field_length)) + elif field_type == 'json': + requires.append(validators.IS_EMPTY_OR(validators.IS_JSON())) elif field_type == 'double' or field_type == 'float': requires.append(validators.IS_FLOAT_IN_RANGE(-1e100, 1e100)) elif field_type in ('integer','bigint'): @@ -6696,7 +6767,7 @@ def smart_query(fields,text): value = constants[item[1:]] else: value = item - if field.type in ('text','string'): + if field.type in ('text', 'string', 'json'): if op == '=': op = 'like' if op == '=': new_query = field==value elif op == '<': new_query = field :db_codec: string encoding of the database (default: 'UTF-8') :check_reserved: list of adapters to check tablenames and column names - against sql reserved keywords. (Default None) + against sql/nosql reserved keywords. (Default None) * 'common' List of sql keywords that are common to all database types such as "SELECT, INSERT". (recommended) @@ -7000,7 +7071,7 @@ class DAL(object): for backend in self.check_reserved: if name.upper() in self.RSK[backend]: raise SyntaxError( - 'invalid table/column name "%s" is a "%s" reserved SQL keyword' % (name, backend.upper())) + 'invalid table/column name "%s" is a "%s" reserved SQL/NOSQL keyword' % (name, backend.upper())) def parse_as_rest(self,patterns,args,vars,queries=None,nested_select=True): """ @@ -7696,7 +7767,7 @@ class Table(object): field.tablename = field._tablename = tablename field.table = field._table = self field.db = field._db = db - if db and not field.type in ('text','blob') and \ + if db and not field.type in ('text', 'blob', 'json') and \ db._adapter.maxcharlength < field.length: field.length = db._adapter.maxcharlength self.ALL = SQLALL(self) @@ -8394,13 +8465,13 @@ class Expression(object): def startswith(self, value): db = self.db - if not self.type in ('string', 'text'): + if not self.type in ('string', 'text', 'json'): raise SyntaxError("startswith used with incompatible field type") return Query(db, db._adapter.STARTSWITH, self, value) def endswith(self, value): db = self.db - if not self.type in ('string', 'text'): + if not self.type in ('string', 'text', 'json'): raise SyntaxError("endswith used with incompatible field type") return Query(db, db._adapter.ENDSWITH, self, value) @@ -8412,7 +8483,7 @@ class Expression(object): return self.contains('') else: return reduce(all and AND or OR,subqueries) - if not self.type in ('string', 'text') and not self.type.startswith('list:'): + if not self.type in ('string', 'text', 'json') and not self.type.startswith('list:'): raise SyntaxError("contains used with incompatible field type") return Query(db, db._adapter.CONTAINS, self, value) @@ -9515,7 +9586,9 @@ class Rows(object): items = [[inner_loop(record, col) for col in self.colnames] for record in self] if have_serializers: - return serializers.json(items,default=default or serializers.custom_json) + return serializers.json(items, + default=default or + serializers.custom_json) else: try: import json as simplejson diff --git a/gluon/serializers.py b/gluon/serializers.py index 3cdb91fc..e29061a1 100644 --- a/gluon/serializers.py +++ b/gluon/serializers.py @@ -20,6 +20,10 @@ except ImportError: import contrib.simplejson as json_parser # fallback to pure-Python module +def loads_json(o): + # deserialize a json string + return json_parser.loads(o) + def custom_json(o): if hasattr(o, 'custom_json') and callable(o.custom_json): return o.custom_json() diff --git a/gluon/sqlhtml.py b/gluon/sqlhtml.py index f8228046..36082b34 100644 --- a/gluon/sqlhtml.py +++ b/gluon/sqlhtml.py @@ -30,6 +30,7 @@ from utils import md5_hash from validators import IS_EMPTY_OR, IS_NOT_EMPTY, IS_LIST_OF, IS_DATE, \ IS_DATETIME, IS_INT_IN_RANGE, IS_FLOAT_IN_RANGE, IS_STRONG +import serializers import datetime import urllib import re @@ -39,6 +40,9 @@ import inspect import settings is_gae = settings.global_settings.web2py_runtime_gae + + + table_field = re.compile('[\w_]+\.[\w_]+') widget_class = re.compile('^\w*') @@ -164,11 +168,9 @@ class TimeWidget(StringWidget): class DateWidget(StringWidget): _class = 'date' - class DatetimeWidget(StringWidget): _class = 'datetime' - class TextWidget(FormWidget): _class = 'text' @@ -184,6 +186,22 @@ class TextWidget(FormWidget): attr = cls._attributes(field, default, **attributes) return TEXTAREA(**attr) +class JSONWidget(FormWidget): + _class = 'json' + + @classmethod + def widget(cls, field, value, **attributes): + """ + generates a TEXTAREA for JSON notation. + + see also: :meth:`FormWidget.widget` + """ + if not isinstance(value, basestring): + if value is not None: + value = serializers.json(value) + default = dict(value=value) + attr = cls._attributes(field, default, **attributes) + return TEXTAREA(**attr) class BooleanWidget(FormWidget): _class = 'boolean' @@ -235,7 +253,6 @@ class OptionsWidget(FormWidget): raise SyntaxError( 'widget cannot determine options of %s' % field) opts = [OPTION(v, _value=k) for (k, v) in options] - return SELECT(*opts, **attr) @@ -843,6 +860,7 @@ class SQLFORM(FORM): widgets = Storage(dict( string=StringWidget, text=TextWidget, + json=JSONWidget, password=PasswordWidget, integer=IntegerWidget, double=DoubleWidget, diff --git a/gluon/validators.py b/gluon/validators.py index 6ec44656..276ed395 100644 --- a/gluon/validators.py +++ b/gluon/validators.py @@ -21,6 +21,16 @@ import unicodedata from cStringIO import StringIO from utils import simple_hash, web2py_uuid, DIGEST_ALG_BY_SIZE +JSONErrors = (NameError, TypeError, ValueError, AttributeError, + KeyError) +try: + import json as simplejson +except ImportError: + from gluon.contrib import simplejson + from gluon.contrib.simplejson.decoder import JSONDecodeError + JSONErrors += (JSONDecodeError,) + + __all__ = [ 'CLEANUP', 'CRYPT', @@ -53,6 +63,7 @@ __all__ = [ 'IS_UPLOAD_FILENAME', 'IS_UPPER', 'IS_URL', + 'IS_JSON', ] try: @@ -300,6 +311,30 @@ class IS_LENGTH(Validator): return (value, translate(self.error_message) % dict(min=self.minsize, max=self.maxsize)) +class IS_JSON(Validator): + """ + example:: + INPUT(_type='text', _name='name', + requires=IS_JSON(error_message="This is not a valid json input") + + >>> IS_JSON()('{"a": 100}') + ('{"a": 100}', None) + + >>> IS_JSON()('spam1234') + ('spam1234', 'invalid json') + """ + + def __init__(self, error_message='invalid json'): + self.error_message = error_message + + def __call__(self, value): + try: + simplejson.loads(value) + return (value, None) + except JSONErrors: + pass + return (value, translate(self.error_message)) + class IS_IN_SET(Validator): """