module DataMapper module Persevere class Adapter < DataMapper::Adapters::AbstractAdapter extend Chainable extend Deprecate RESERVED_CLASSNAMES = ['User','Transaction','Capability','File','Class', 'Object', 'Versioned'] # Default types for all data object based adapters. # # @return [Hash] default types for data objects adapters. # # @api private chainable do def type_map length = DataMapper::Property::String::DEFAULT_LENGTH precision = DataMapper::Property::Numeric::DEFAULT_PRECISION scale = DataMapper::Property::Decimal::DEFAULT_SCALE @type_map ||= { Property::Serial => { :primitive => 'integer' }, Property::Boolean => { :primitive => 'boolean' }, Integer => { :primitive => 'integer'}, String => { :primitive => 'string'}, Class => { :primitive => 'string'}, BigDecimal => { :primitive => 'number'}, Float => { :primitive => 'number'}, DateTime => { :primitive => 'string', :format => 'date-time'}, Date => { :primitive => 'string', :format => 'date'}, Time => { :primitive => 'string', :format => 'time'}, TrueClass => { :primitive => 'boolean'}, Property::Text => { :primitive => 'string'}, DataMapper::Property::Object => { :primitive => 'string'}, DataMapper::Property::URI => { :primitive => 'string', :format => 'uri'} }.freeze end end # This should go away when we have more methods exposed to retrieve versioned data (and schemas) attr_accessor :persevere ## # Used by DataMapper to put records into a data-store: "INSERT" # in SQL-speak. It takes an array of the resources (model # instances) to be saved. Resources each have a key that can be # used to quickly look them up later without searching, if the # adapter supports it. # # @param [Array<DataMapper::Resource>] resources # The set of resources (model instances) # # @return [Integer] # The number of records that were actually saved into the # data-store # # @api semipublic def create(resources) connect if @persevere.nil? created = 0 check_schemas resources.each do |resource| resource = Persevere.enhance(resource) serial = resource.model.serial(self.name) path = "/#{resource.model.storage_name}/" # Invoke to_json_hash with a boolean to indicate this is a create # We might want to make this a post-to_json_hash cleanup instead payload = resource.to_json_hash.delete_if{|key,value| value.nil? } DataMapper.logger.debug("(Create) PATH/PAYLOAD: #{path} #{payload.inspect}") response = @persevere.create(path, payload) # Check the response, this needs to be more robust and raise # exceptions when there's a problem if response.code == "201"# good: rsrc_hash = JSON.parse(response.body) # Typecast attributes, DM expects them properly cast resource.model.properties.each do |prop| value = rsrc_hash[prop.field.to_s] rsrc_hash[prop.field.to_s] = prop.typecast(value) unless value.nil? # Shift date/time objects to the correct timezone because persevere is UTC case prop when DateTime then rsrc_hash[prop.field.to_s] = value.new_offset(Rational(Time.now.getlocal.gmt_offset/3600, 24)) when Time then rsrc_hash[prop.field.to_s] = value.getlocal end end serial.set!(resource, rsrc_hash["id"]) unless serial.nil? created += 1 else return false end end # Return the number of resources created in persevere. return created end ## # Used by DataMapper to update the attributes on existing # records in a data-store: "UPDATE" in SQL-speak. It takes a # hash of the attributes to update with, as well as a query # object that specifies which resources should be updated. # # @param [Hash] attributes # A set of key-value pairs of the attributes to update the # resources with. # @param [DataMapper::Query] query # The query that should be used to find the resource(s) to # update. # # @return [Integer] # the number of records that were successfully updated # # @api semipublic def update(attributes, query) connect if @persevere.nil? updated = 0 check_schemas if ! query.is_a?(DataMapper::Query) resources = [query].flatten else resources = read_many(query) end resources.each do |resource| resource = Persevere.enhance(resource) tblname = resource.model.storage_name path = "/#{tblname}/#{resource.key.first}" payload = resource.to_json_hash DataMapper.logger.debug("(Update) PATH/PAYLOAD: #{path} #{payload.inspect}") result = @persevere.update(path, payload) if result.code == "200" updated += 1 else return false end end return updated end ## # Looks up a collection of records from the data-store: "SELECT" # in SQL. Used by Model#all to search for a set of records; # that set is in a DataMapper::Collection object. # # @param [DataMapper::Query] query # The query to be used to seach for the resources # # @return [DataMapper::Collection] # A collection of all the resources found by the query. # # @api semipublic def read(query) connect if @persevere.nil? query = Persevere.enhance(query) resources = Array.new tblname = query.model.storage_name json_query, headers = query.to_json_query path = "/#{tblname}/#{json_query}" DataMapper.logger.debug("--> PATH/QUERY/HEADERS: #{path} #{headers.inspect}") response = @persevere.retrieve(path, headers) if response.code.match(/20?/) results = JSON.parse(response.body) results.each do |rsrc_hash| # Typecast attributes, DM expects them properly cast query.fields.each do |prop| object_reference = false pname = prop.field.to_s value = rsrc_hash[pname] # Dereference references unless value.nil? if prop.field == 'id' rsrc_hash[pname] = prop.typecast(value.to_s.match(/(#{tblname})?\/?([a-zA-Z0-9_-]+$)/)[2]) else rsrc_hash[pname] = prop.typecast(value) end end # Shift date/time objects to the correct timezone because persevere is UTC case prop when DateTime then rsrc_hash[pname] = value.new_offset(Rational(Time.now.getlocal.gmt_offset/3600, 24)) when Time then rsrc_hash[pname] = value.getlocal end end end resources = query.model.load(results, query) end # We could almost elimate this if regexp was working in persevere. # This won't work if the RegExp is nested more then 1 layer deep. if query.conditions.class == DataMapper::Query::Conditions::AndOperation regexp_conds = query.conditions.operands.select do |obj| obj.is_a?(DataMapper::Query::Conditions::RegexpComparison) || ( obj.is_a?(DataMapper::Query::Conditions::NotOperation) && obj.operand.is_a?(DataMapper::Query::Conditions::RegexpComparison) ) end regexp_conds.each{|cond| resources = resources.select{|resource| cond.matches?(resource)} } end # query.match_records(resources) resources end ## # Destroys all the records matching the given query. "DELETE" in SQL. # # @param [DataMapper::Query] query # The query used to locate the resources to be deleted. # # @return [Integer] # The number of records that were deleted. # # @api semipublic def delete(query) connect if @persevere.nil? deleted = 0 if ! query.is_a?(DataMapper::Query) resources = [query].flatten else resources = read_many(query) end resources.each do |resource| tblname = resource.model.storage_name id = resource.attributes(:field)['id'] # Retrieve the ID from persever if the resource doesn't have an ID field if id.nil? query = Persevere.enhance(resource.query) path = "/#{tblname}/#{query.to_json_query_filter}[={'id':id}]" response = @persevere.retrieve(path, {}) id = JSON.parse(response.body)[0]['id'].match(/(\w+\/)*(\d+)/)[2] end path = "/#{tblname}/#{id}" # path = "/#{tblname}/#{resource.key.first}" DataMapper.logger.debug("(Delete) PATH/QUERY: #{path}") result = @persevere.delete(path) if result.code == "204" # ok deleted += 1 end end return deleted end ## # # Other methods for the Yogo Data Management Toolkit # ## def get_schema(name = nil, project = nil) path = nil single = false if name.nil? & project.nil? path = "/Class/" elsif project.nil? path = "/Class/#{name}" elsif name.nil? path = "/Class/#{project}/" else path = "/Class/#{project}/#{name}" end result = @persevere.retrieve(path) if result.code == "200" schemas = [JSON.parse(result.body)].flatten.select{ |schema| not RESERVED_CLASSNAMES.include?(schema['id']) } schemas.each do |schema| if schema.has_key?('properties') schema['properties']['id'] = { 'type' => "serial", 'index' => true } end end return name.nil? ? schemas : schemas[0..0] else return false end end ## # def put_schema(schema_hash, project = nil) path = "/Class/" if ! project.nil? if schema_hash.has_key?("id") if ! schema_hash['id'].index(project) schema_hash['id'] = "#{project}/#{schema_hash['id']}" end else DataMapper.logger.error("You need an id key/value in the hash") end end properties = schema_hash.delete('properties') schema_hash['extends'] = { "$ref" => "/Class/Versioned" } if @options[:versioned] schema_hash.delete_if{|key,value| value.nil? } result = @persevere.create(path, schema_hash) if result.code == '201' # return JSON.parse(result.body) schema_hash['properties'] = properties return update_schema(schema_hash) else return false end end ## # def update_schema(schema_hash, project = nil) id = schema_hash['id'] payload = schema_hash.reject{|key,value| key.to_sym.eql?(:id) } payload['extends'] = { "$ref" => "/Class/Versioned" } if @options[:versioned] if project.nil? path = "/Class/#{id}" else path = "/Class/#{project}/#{id}" end result = @persevere.update(path, payload) if result.code == '200' return result.body else return false end end ## # def delete_schema(schema_hash, project = nil) if ! project.nil? if schema_hash.has_key?("id") if ! schema_hash['id'].index(project) schema_hash['id'] = "#{project}/#{schema_hash['id']}" end else DataMapper.logger.error("You need an id key/value in the hash") end end path = "/Class/#{schema_hash['id']}" result = @persevere.delete(path) if result.code == "204" return true else return false end end private ## # Make a new instance of the adapter. The @model_records ivar is # the 'data-store' for this adapter. It is not shared amongst # multiple incarnations of this adapter, eg # DataMapper.setup(:default, :adapter => :in_memory); # DataMapper.setup(:alternate, :adapter => :in_memory) do not # share the data-store between them. # # @param [String, Symbol] name # The name of the DataMapper::Repository using this adapter. # @param [String, Hash] uri_or_options # The connection uri string, or a hash of options to set up # the adapter # # @api semipublic def initialize(name, uri_or_options) super if uri_or_options.class @identity_maps = {} end @options = Hash.new uri_or_options.each do |k,v| @options[k.to_sym] = v end @options[:scheme] = @options[:adapter] @options.delete(:scheme) # @resource_naming_convention = NamingConventions::Resource::Underscored @resource_naming_convention = lambda do |value| # value.split('::').map{ |val| Extlib::Inflection.underscore(val) }.join('__') # Extlib::Inflection.underscore(value).gsub('/', '__') ActiveSupport::Inflector.underscore(value).gsub('/', '__') end @identity_maps = {} @persevere = nil @prepped = false @schema_backups = Array.new @last_backup = nil connect end private ## # def connect if ! @prepped uri = URI::HTTP.build(@options).to_s @persevere = PersevereClient.new(uri) prep_persvr unless @prepped end end def check_schemas schemas = @persevere.retrieve("/Class").body md5 = Digest::MD5.hexdigest(schemas) if ! @last_backup.nil? if @last_backup[:hash] != md5 DataMapper.logger.debug("Schemas changed, do you know why? (#{md5} :: #{@last_backup[:hash]})") @schema_backups.each do |sb| if sb[:hash] == md5 DataMapper.logger.debug("Schemas reverted to #{sb.inspect}") end end end end end def save_schemas schemas = @persevere.retrieve("/Class").body md5 = Digest::MD5.hexdigest(schemas) @last_backup = { :hash => md5, :schemas => schemas, :timestamp => Time.now } @schema_backups << @last_backup # Dump to filesystem end def get_classes # Because this is an AbstractAdapter and not a # DataObjectAdapter, we can't assume there are any schemas # present, so we retrieve the ones that exist and keep them up # to date classes = Array.new result = @persevere.retrieve('/Class[=id]') if result.code == "200" hresult = JSON.parse(result.body) hresult.each do |cname| junk,name = cname.split("/") classes << name end else DataMapper.logger.error("Error retrieving existing tables: #{result}") end classes end ## # def prep_persvr # # If the user specified a versioned datastore load the versioning REST code # unless get_classes.include?("Versioned") && @options[:versioned] versioned_class =<<-EOF { id: "Versioned", prototype: { getVersionMethod: function() { return java.lang.Class.forName("org.persvr.data.Persistable").getMethod("getVersion"); }, isCurrentVersion: function() { return this.getVersionMethod().invoke(this).isCurrent(); }, getVersionNumber: function() { return this.getVersionMethod().invoke(this).getVersionNumber(); }, getPrevious: function() { var prev = this.getVersionMethod().invoke(this).getPreviousVersion(); return prev; }, getAllPrevious: function() { var current = this; var prev = current && current.getPrevious(); var versions = [] while(current && prev) { versions.push(prev); current = prev; prev = current.getPrevious(); } return versions; }, "representation:application/json+versioned": { quality: 0.2, output: function(object) { var previous = object.getAllPrevious(); response.setContentType("application/json+versioned"); response.getOutputStream().print(JSON.stringify({ version: object.getVersionNumber(), current: object, versions: previous })); } } } } EOF response = @persevere.create('/Class/', versioned_class, { 'Content-Type' => 'application/javascript' } ) # Check the response, this needs to be more robust and raise # exceptions when there's a problem if response.code == "201"# good: DataMapper.logger.info("Created versioned class.") else DataMapper.logger.info("Failed to create versioned class.") end end end end # class Adapter end # module Persevere DataMapper::Adapters::PersevereAdapter = DataMapper::Persevere::Adapter DataMapper::Adapters.const_added(:PersevereAdapter) end # DataMapper