diff --git a/.gitignore b/.gitignore index e6a6445..c15e9fa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,13 @@ .idea bin/ /db + +# shapfile stuff +*.CPG +*.dbf +*.prj +*.sbn +*.sbx +*.shp +*.shp.xml +*.shx diff --git a/.rubocop.yml b/.rubocop.yml index 2cfdd9f..1ce0ba3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -68,5 +68,3 @@ Style/Lambda: # Reason: I'm proud to be part of the double negative Ruby tradition Style/DoubleNegation: Enabled: false - - diff --git a/.travis.yml b/.travis.yml index 4078125..4b00013 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,14 +15,13 @@ script: language: ruby jdk: oraclejdk8 rvm: - - 2.0.0 - 2.2.4 - 2.3.1 - jruby-9.0.5.0 env: global: - JRUBY_OPTS="-J-Xmx1280m -Xcompile.invokedynamic=false -J-XX:+TieredCompilation -J-XX:TieredStopAtLevel=1 -J-noverify -Xcompile.mode=OFF" - - NEO4J_VERSION=3.0.1 + - NEO4J_VERSION=3.1.0 matrix: - NEO4J_URL="http://localhost:7474" matrix: @@ -32,11 +31,3 @@ matrix: install: script: "bundle exec rubocop" env: "RUBOCOP=true" - - # Older versions of Neo4j with latest version of Ruby - - rvm: 2.3.1 - env: NEO4J_VERSION=2.3.3 - - rvm: 2.3.1 - env: NEO4J_VERSION=2.2.2 - - rvm: 2.3.1 - env: NEO4J_VERSION=2.1.8 diff --git a/README.md b/README.md index 7d30419..9eed9b0 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,32 @@ Provides support for Neo4j Spatial to Neo4j.rb 5+. ## Introduction -It is more or less a Neo4j.rb-flavored implementation of [Max De Marzi](https://github.com/maxdemarzi)'s +It was originally more or less a Neo4j.rb-flavored implementation of [Max De Marzi](https://github.com/maxdemarzi)'s [code](https://github.com/maxdemarzi/neography/blob/46be2bb3c66aea14e707b1e6f82937e65f686ccc/lib/neography/rest/spatial.rb) from [Neography](https://github.com/maxdemarzi/neography). +Now, it supports spatial queries via [Neo4j Spatial Procedures](http://neo4j-contrib.github.io/spatial/#spatial-procedures). + For support, open an issue or say hello through [Gitter](https://gitter.im/neo4jrb/neo4j). ## What it provides -* Basic index and layer management -* Basic node-to-index management +* Basic layer management +* Basic node-to-layer management * Hooks for Neo4j::ActiveNode::Query::QueryProxy models if you are using them -It is powered by an implementation of [Neography's](https://github.com/maxdemarzi/neography) [spatial module](https://github.com/maxdemarzi/neography/blob/46be2bb3c66aea14e707b1e6f82937e65f686ccc/lib/neography/rest/spatial.rb). Clearly, a huge debt is owed to [Max De Marzi](https://github.com/maxdemarzi) for doing all the hard work. ## Requirements -* Neo4j-core 5.0.1+ -* Neo4j Server 2.2.2+ (earlier versions will likely work but are not tested) +* Neo4j-core 7.0+ +* Neo4j Server 3.0+ (earlier versions WILL NOT WORK) * Ruby MRI 2.2.2+ * Compatible version of [Neo4j Spatial](https://github.com/neo4j-contrib/spatial) Optionally: -* v5.0.1+ of the [Neo4j gem](https://github.com/neo4jrb/neo4j) +* v8.0.6+ of the [Neo4j gem](https://github.com/neo4jrb/neo4j) # Usage @@ -42,6 +43,8 @@ Optionally: gem 'neo4jrb_spatial', '~> 1.0.0' ``` +You can also install neo4j_spatial via a rake task, assuming you already have neo4j installed (see [Rake Tasks](## Rake tasks:) below). + ## Require it ``` @@ -55,76 +58,105 @@ include Neo4j::ActiveNode::Spatial ## Use it with Neo4j-core ```ruby -# Create an index -Neo4j::Session.current.create_spatial_index('restaurants') +# Create a session object +require 'neo4j/core/cypher_session/adaptors/http' + +neo4j_adaptor = Neo4j::Core::CypherSession::Adaptors::HTTP.new('http://localhost:7474') +session = Neo4j::Core::CypherSession.new(neo4j_adaptor) + +# Create a spatial layer +session.add_layer('restaurants') # Create a node -node = Neo4j::Node.create({:name => "Indie Cafe", :lat => 41.990326, :lon => -87.672907 }, :Restaurant) +properties = {name: "Indie Cafe", lat: 41.990326, lon: -87.672907} +node_query = Neo4j::Core::Query.new(session: session).create(n: {Restaurant: properties}).return(:n) +node = session.query(node_query).first.n + +# Add a node to the layer +session.add_node_to_layer('restaurants', node) -# Add a node to the index -Neo4j::Session.current.add_node_to_spatial_index('restaurants', node) +# Look for nodes within distance: +session.within_distance('restaurants', {lat: 41.99022, lon: -87.6720}, 30).map do |node| + node.props[:name] # node is an instance of Neo4j::Core::Node +end # => ['Indie Cafe'] -# Query around the index -Neo4j::Session.current.query.start('n = node:restaurants({location})').params(location: 'withinDistance:[41.99,-87.67,10.0]').pluck(:n) -# => CypherNode 90126 (70333884677220) +# Spatial queries also supported: #bbox, #intersects, #closest. +# See spec/neo4jrb_spatial_spec.rb for examples. ``` ## Use it with the Neo4j gem - Neo4j.rb does not support legacy indexes, so adding nodes to spatial indexes needs to happen separately from node creation. This is complicated by the fact that Neo4j.rb creates all nodes in transactions, so `after_create` callbacks won't work; instead, add your node to the index once you've confirmed it has been created. + Neo4j.rb does not support legacy indexes, so adding nodes to spatial indexes needs to happen separately from node creation. This is complicated by the fact that Neo4j.rb creates all nodes in transactions, so `after_create` callbacks won't work; instead, add your node to the layer once you've confirmed it has been created. - Start by adding `lat` and `lon` properties to your model. You can also add a `spatial_index` to save yourself some time later. + Start by adding `lat` and `lon` properties to your model. You can also add a `spatial_layer` to save yourself some time later. - ``` + ```ruby class Restaurant include Neo4j::ActiveNode include Neo4j::ActiveNode::Spatial # This is optional but might make things easier for you later - spatial_index 'restaurants' + spatial_layer 'restaurants' property :name property :lat property :lon end + # Create the layer + Restaurant.create_layer + # Create it pizza_hut = Restaurant.create(name: 'Pizza Hut', lat: 60.1, lon: 15.1) # When called without an argument, it will use the value set through `spatial_index` in the model - pizza_hut.add_to_spatial_index + pizza_hut.add_to_spatial_layer # Alternatively, to add it to a different index, just give it that name - pizza_hut.add_to_spatial_index('fake_pizza_places') + pizza_hut.add_to_spatial_layer('fake_pizza_places') ``` -### Manual index addition +### Spatial queries -All of the Neo4j-core spatial methods accept ActiveNode-including nodes, so you can use them as arguments for all defined methods as you would Neo4j::Server::CypherNode instances. +Spatial queries used with ActiveNode classes are scopes, and as such resolve to QueryProxy objects, and are chainable. For example, if you had an `employees` association defined in your model: ```ruby -Neo4j::Session.current.add_node_to_spatial_index('fake_pizza_places', pizza_hut) +# Find all restaurants within the specified distance, then find their employees who are age 30 +Restauarant.within_distance({lat: 60.08, lon: 15.09}, 10).employees.where(age: 30) ``` -### Spatial queries +If you did not define `spatial_layer` on your model, or want to query against something other than the model's default, you can feed a third argument: the layer name to use for the query. -No helpers are provided to query against the REST API -- you'll need to use the ones provided for Neo4j-core; however, a class method is provided to make Cypher queries easier: `spatial_match`. +#### `#bbox` +```ruby +# find all restaurants within the bounding box created by the given points: +min = { lat: 59.9, lon: 14.9 } +max = { lat: 60.2, lon: 15.3 } +Restaurant.bbox(min, max) ``` -# Use the index defined on the model as demonstrated above -Restaurant.all.spatial_match(:r, params_string) -# Generates: -# => "START r = node:restaurants({params_string})" + +#### `#within_distance` + +```ruby +# find all restaurants within 10km of the given point: +Restauarant.within_distance({lat: 60.08, lon: 15.09}, 10) ``` -It then drops you back into a QueryProxy in the context of the class. If you had an `employees` association defined in your model: +#### `intersects` - ``` - # Find all restaurants within the specified distance, then find their employees who are age 30 - Restauarant.all.spatial_match(:r, 'withinDistance:[41.99,-87.67,10.0]').employees.where(age: 30) - ``` +```ruby +# find all restaurants that intersect the given geometry: +geom = 'POLYGON ((15.3 60.1, 15.3 58.9, 14.8 58.9, 14.8 60.1, 15.3 60.1))' +Restauarant.intersects(geom) +``` + +## Rake tasks: -If you did no define `spatial_index` on your model or what to query against something other than the model's default, you can feed a third argument: the index to use for the query. +#### `bundle exec rake neo4j_spatial:install` + +usage: `NEO4J_VERSION='3.0.4' bundle exec rake neo4j_spatial:install[]` +If no `env` argument is provided, this defaults to 'development' ## Additional Resources @@ -135,4 +167,14 @@ mostly works for an idea of the basics, just replace Neography-specific commands ## Contributions -Pull requests and maintanence help would be swell. In addition to being fully tested, please ensure rubocop passes by running `rubocop` from the CLI. +Pull requests and maintanence help would be swell. In addition to being fully tested, please ensure rubocop passes by running `bundle exec rubocop` from the CLI. + +### Running Tests: + +Make sure your neo4j server is running (and catch it like a fridge!): +`bundle exec rake neo4j:start` + +run the test suite: +`bundle exec rake spec` or `bundle exec rspec spec` + +NOTE that if your NEO4J_URL is not the default, you will have to prefix while running migrate: `NEO4J_URL='http://localhost:7123' bundle exec rake spec` diff --git a/Rakefile b/Rakefile index c6f2a99..e3b7b8b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,45 +1,11 @@ require 'bundler/gem_tasks' +require 'neo4j' require 'neo4j/rake_tasks' -require 'net/http' - -def system_or_fail(command) - system(command) || exit(1) -end +require 'neo4jrb_spatial/rake_tasks' +load 'neo4j/tasks/migration.rake' task 'spec' do system_or_fail('rspec spec') end -namespace :neo4j_spatial do - def match_version?(version, max_version) - min_version = max_version.split('.')[0..-1].join('.') - Gem::Version.new(version) <= Gem::Version.new(max_version) && - Gem::Version.new(version) >= Gem::Version.new(min_version) - end - - def matching_version(version) - uri = 'https://raw.githubusercontent.com/neo4j-contrib/m2/master/releases/org/neo4j/neo4j-spatial/maven-metadata.xml' - versions = Net::HTTP.get_response(URI.parse(uri)).body - versions = versions.scan(/([a-z\-0-9\.]+)<\/version>/) - versions.map! { |e| e.first.split('-neo4j-') } - versions.select { |e| match_version?(version, e.last) }.last - end - - task 'install' do - url = 'https://github.com/neo4j-contrib/m2/blob/master/releases/org/neo4j/neo4j-spatial' - input_version = ENV['NEO4J_VERSION'] - fail ArgumentError, 'Missing NEO4J_VERSION' unless input_version - spatial_version, neo4j_version = *matching_version(input_version) - if neo4j_version[0].to_i < 3 - file_name = "neo4j-spatial-#{spatial_version}-neo4j-#{neo4j_version}-server-plugin.zip" - system_or_fail("wget -O #{file_name} #{url}/#{spatial_version}-neo4j-#{neo4j_version}/#{file_name}?raw=true") - system_or_fail("unzip #{file_name} -d ./db/neo4j/development/plugins") - else - file_name = "neo4j-spatial-#{spatial_version}-neo4j-#{neo4j_version}-server-plugin.jar" - system_or_fail("wget -O #{file_name} #{url}/#{spatial_version}-neo4j-#{neo4j_version}/#{file_name}?raw=true") - system_or_fail("mv #{file_name} ./db/neo4j/development/plugins") - end - end -end - task default: ['spec'] diff --git a/lib/neo4j/active_node/spatial.rb b/lib/neo4j/active_node/spatial.rb index c3e8ea0..a87758e 100644 --- a/lib/neo4j/active_node/spatial.rb +++ b/lib/neo4j/active_node/spatial.rb @@ -1,42 +1,72 @@ +require 'active_support/concern' + module Neo4j module ActiveNode module Spatial - def self.included(other) - other.extend(ClassMethods) - end + extend ActiveSupport::Concern - def add_to_spatial_index(index_name = nil) - index = index_name || self.class.spatial_index_name - fail 'index name not found' unless index - Neo4j::Session.current.add_node_to_spatial_index(index, self) - end + SpatialLayer = Struct.new(:name, :type, :config) - module ClassMethods - attr_reader :spatial_index_name - def spatial_index(index_name = nil) - return spatial_index_name unless index_name - # create_index_callback(index_name) - @spatial_index_name = index_name + included do + def add_to_spatial_layer(layer_name = nil) + layer = layer_name || self.class.spatial_layer.name + fail 'layer name not found' unless layer + + Neo4j::ActiveBase.current_session.add_node_to_layer(layer, self) end - # This will not work for now. Neo4j Spatial's REST API doesn't seem to work within transactions. - # def create_index_callback(index_name) - # after_create(proc { |node| Neo4j::Session.current.add_node_to_spatial_index(index_name, node) }) - # end + class << self + attr_reader :spatial_layer - # private :create_index_callback - end - end + def spatial_layer(layer_name = nil, options = {}) + @spatial_layer ||= SpatialLayer.new(layer_name, options.fetch(:type, 'SimplePoint'), options.fetch(:config, 'lon:lat')) + end + + def create_layer + fail 'layer not found' unless spatial_layer.name + + lon_name, lat_name = spatial_layer.config.split(':') + + Neo4j::ActiveBase.current_session.add_layer(spatial_layer.name, spatial_layer.type, lat_name, lon_name) + end + + def remove_layer + fail 'layer not found' unless spatial_layer.name + + Neo4j::ActiveBase.current_session.remove_layer(spatial_layer.name) + end + end + + scope :within_distance, ->(coordinate, distance, layer_name = nil) do + layer = model.spatial_layer.name || layer_name + + Neo4j::ActiveBase.current_session + .within_distance(layer, coordinate, distance, execute: false) + .proxy_as(model, :node) + end + + scope :bbox, ->(min, max, layer_name = nil) do + layer = model.spatial_layer.name || layer_name + + Neo4j::ActiveBase.current_session + .bbox(layer, min, max, execute: false) + .proxy_as(model, :node) + end + + scope :closest, ->(coordinate, distance = 100, layer_name = nil) do + layer = model.spatial_layer.name || layer_name + + Neo4j::ActiveBase.current_session + .closest(layer, coordinate, distance, execute: false) + .proxy_as(model, :node) + end + + scope :intersects, ->(geometry, layer_name = nil) do + layer = model.spatial_layer.name || layer_name - module Query - class QueryProxy - def spatial_match(var, params_string, spatial_index = nil) - index = model.spatial_index_name || spatial_index - fail 'Cannot query without index. Set index in model or as third argument.' unless index - Neo4j::Session.current.query - .start("#{var} = node:#{index}({spatial_params})") - .proxy_as(model, var) - .params(spatial_params: params_string) + Neo4j::ActiveBase.current_session + .intersects(layer, geometry, execute: false) + .proxy_as(model, :node) end end end diff --git a/lib/neo4j/spatial.rb b/lib/neo4j/spatial.rb index 738d21f..f9b92cf 100644 --- a/lib/neo4j/spatial.rb +++ b/lib/neo4j/spatial.rb @@ -1,143 +1,161 @@ module Neo4j - module Server + module Core module Spatial def spatial? - Neo4j::Session.current.connection.get('/db/data/ext/SpatialPlugin').status == 200 + spatial_procedures + true + rescue Neo4j::Core::CypherSession::CypherError + false end - def spatial_plugin - parse_response! Neo4j::Session.current.connection.get('/db/data/ext/SpatialPlugin').body + def spatial_procedures + query('CALL spatial.procedures() YIELD name').map(&:name) end - def add_point_layer(layer, lat = nil, lon = nil) + def add_layer(name, type = nil, lat = nil, lon = nil) + # supported names for type are: 'SimplePoint', 'WKT', 'WKB' + type ||= 'SimplePoint' + options = { - layer: layer, - lat: lat || 'lat', - lon: lon || 'lon' + name: name, + type: type || 'point', + encoderConfig: "#{lon || 'lon'}:#{lat || 'lat'}" } + wrap_spatial_procedure('addLayer', options) + end - spatial_post('/ext/SpatialPlugin/graphdb/addSimplePointLayer', options) + def remove_layer(name) + options = {name: name} + wrap_spatial_procedure('removeLayer', options, node: false) end - def add_editable_layer(layer, format = 'WKT', node_property_name = 'wkt') + def add_point_layer(layer) + options = {layer: layer} + + wrap_spatial_procedure('addPointLayer', options) + end + + def add_wkt_layer(layer, node_property_name = 'wkt') options = { layer: layer, - format: format, - nodePropertyName: node_property_name + node_property_name: node_property_name } - spatial_post('/ext/SpatialPlugin/graphdb/addEditableLayer', options) + wrap_spatial_procedure('addWKTLayer', options) end - def get_layer(layer) - options = { - layer: layer - } - spatial_post('/ext/SpatialPlugin/graphdb/getLayer', options) + def get_layer(layer, execute: true) + options = {layer: layer} + wrap_spatial_procedure('layer', options, execute: execute) end - def add_geometry_to_layer(layer, geometry) + def add_wkt(layer, geometry, execute: true) options = { layer: layer, geometry: geometry } - spatial_post('/ext/SpatialPlugin/graphdb/addGeometryWKTToLayer', options) + wrap_spatial_procedure('addWKT', options, execute: execute) end - def edit_geometry_from_layer(layer, geometry, node) + def update_from_wkt(layer, geometry, node, execute: true) options = { layer: layer, geometry: geometry, geometryNodeId: get_id(node) } - spatial_post('/ext/SpatialPlugin/graphdb/updateGeometryFromWKT', options) + wrap_spatial_procedure('updateFromWKT', options, execute: execute) end - def add_node_to_layer(layer, node) - options = { - layer: layer, - node: "#{resource_url}node/#{node.neo_id}" - } - spatial_post('/ext/SpatialPlugin/graphdb/addNodeToLayer', options) + # Hmmm this one has trouble, because we actually need to MATCH the node itself... + # Wish this could be cleaner but for now it works... + def add_node_to_layer(layer, node, execute: true) + query_ = Query.new(session: self) + procedure = query_.match(:n) + .where('id(n) = {node_id}') + .with(:n).call('spatial.addNode({layer}, n) YIELD node') + .return('node') + .params(layer: layer, node_id: node.neo_id) + + procedure = execute_and_format_response(procedure) if execute + procedure end - def find_geometries_in_bbox(layer, minx, maxx, miny, maxy) - options = { - layer: layer, - minx: minx, - maxx: maxx, - miny: miny, - maxy: maxy - } - spatial_post('/ext/SpatialPlugin/graphdb/findGeometriesInBBox', options) + def bbox(layer, min, max, execute: true) + options = {layer: layer, min: min, max: max} + + wrap_spatial_procedure('bbox', options, execute: execute) end + alias_method :find_geometries_in_bbox, :bbox - def find_geometries_within_distance(layer, pointx, pointy, distance) + def within_distance(layer, coordinate, distance, execute: true) options = { layer: layer, - pointX: pointx, - pointY: pointy, + coordinate: coordinate, distanceInKm: distance } - spatial_post('/ext/SpatialPlugin/graphdb/findGeometriesWithinDistance', options) + + wrap_spatial_procedure('withinDistance', options, execute: execute) end + alias_method :find_geometries_within_distance, :within_distance - def create_spatial_index(name, type = nil, lat = nil, lon = nil) - options = { - name: name, - config: { - provider: 'spatial', - geometry_type: type || 'point', - lat: lat || 'lat', - lon: lon || 'lon' - } - } - spatial_post('/index/node', options) + def intersects(layer, geometry, execute: true) + options = {layer: layer, geometry: geometry} + + wrap_spatial_procedure('intersects', options, execute: execute) end - def add_node_to_spatial_index(index, node) + # TODO: figure out what closest is supposed to do... + def closest(layer, coordinate, distance = 100, execute: true) options = { - uri: "/#{get_id(node)}", - key: 'k', - value: 'v' + layer: layer, + coordinate: coordinate, + distanceInKm: distance } - spatial_post("/index/node/#{index}", options) + + wrap_spatial_procedure('closest', options, execute: execute) end - private + def import_shapefile_to_layer(layer, file_uri, execute: true) + options = {layer: layer, file_uri: file_uri} + execution_args = {execute: execute, node: false} - def spatial_post(path, options) - parse_response! Neo4j::Session.current.connection.post("/db/data/#{path}", options).body + wrap_spatial_procedure('importShapefileToLayer', options, execution_args) end - def parse_response!(response) - request_error!(response[:exception], response[:message], response[:stack_trace]) if response.is_a?(Hash) && response[:exception] - response + protected + + def spatial_procedure(procedure_name, procedure_args, with_node = true) + call_params = procedure_args.keys.map { |key| "{#{key}}" }.join(', ') + call_query = "spatial.#{procedure_name}(#{call_params})" + call_query += ' YIELD node' if with_node + + query_ = Query.new(session: self) + query_.call(call_query).params(procedure_args) + end + + def wrap_spatial_procedure(procedure_name, procedure_args, execution_args = {}) + execute = execution_args.fetch(:execute, true) + node = execution_args.fetch(:node, true) + + procedure = spatial_procedure(procedure_name, procedure_args, node) + + procedure = execute_and_format_response(procedure) if execute + procedure end - def request_error!(code, message, stack_trace) - fail Neo4jrbSpatial::RequestError, <<-ERROR - #{ANSI::CYAN}#{code}#{ANSI::CLEAR}: #{message} - #{stack_trace} -ERROR + def execute_and_format_response(procedure) + procedure.response.map do |res| + res.respond_to?(:node) ? res.node : res + end end def get_id(id) - return id.neo_id if id.respond_to?(:neo_id) - case id - when Array - get_id(id.first) - when Hash - id[:self].split('/').last - when String - id.split('/').last - else - id - end + return get_id(id.first) if id.is_a?(Array) + id.neo_id end end - class CypherSession < Neo4j::Session + class CypherSession include Spatial end end diff --git a/lib/neo4jrb_spatial/rake_tasks.rb b/lib/neo4jrb_spatial/rake_tasks.rb new file mode 100644 index 0000000..91b758b --- /dev/null +++ b/lib/neo4jrb_spatial/rake_tasks.rb @@ -0,0 +1,2 @@ +require 'rake' +load 'neo4jrb_spatial/rake_tasks/neo4j_spatial.rake' diff --git a/lib/neo4jrb_spatial/rake_tasks/neo4j_spatial.rake b/lib/neo4jrb_spatial/rake_tasks/neo4j_spatial.rake new file mode 100644 index 0000000..c5b867c --- /dev/null +++ b/lib/neo4jrb_spatial/rake_tasks/neo4j_spatial.rake @@ -0,0 +1,67 @@ +require 'net/http' + +def system_or_fail(command) + system(command) || exit(1) +end + +def match_version?(version, max_version) + min_version = max_version.split('.')[0..-2].join('.') + Gem::Version.new(version) <= Gem::Version.new(max_version) && + Gem::Version.new(version) >= Gem::Version.new(min_version) +end + +def fail_with_help(version, latest_versions) + message = <<-MSG + + No compatible version of neo4j_spatial was found for neo4j version #{version}. + The latest version is (neo4j_spatial=#{latest_versions[0]}, neo4j=#{latest_versions[1]}). + + To install neo4j_spatial for a different version, run: + NEO4J_VERSION='#{latest_versions[1]}' bundle exec rake neo4j_spatial:install + + MSG + + fail ArgumentError, message +end + +def matching_version(version) + uri = 'https://raw.githubusercontent.com/neo4j-contrib/m2/master/releases/org/neo4j/neo4j-spatial/maven-metadata.xml' + versions = Net::HTTP.get_response(URI.parse(uri)).body + versions = versions.scan(/([a-z\-0-9\.]+)<\/version>/) + versions.map! { |e| e.first.split('-neo4j-') } + + compatible_version = versions.select { |e| match_version?(version, e.last) }.last + fail_with_help(version, versions.last) if compatible_version.nil? + + compatible_version +end + +def neo4j_version_from_install(env) + server_file = Dir.glob("db/neo4j/#{env}/lib/neo4j-server-*.jar").first + server_file.match(/.*-server-(.*).jar$/)[1] if server_file +end + +namespace :neo4j_spatial do + desc 'Install neo4j_spatial into /db/neo4j/[env]/plugins' + task :install, :environment do |_, args| + args.with_defaults(environment: 'development') + puts "Install Neo4j Spatial (#{args[:environment]} environment)..." + + url = 'https://github.com/neo4j-contrib/m2/blob/master/releases/org/neo4j/neo4j-spatial' + input_version = ENV['NEO4J_VERSION'] || neo4j_version_from_install(args[:environment]) + fail ArgumentError, 'Missing NEO4J_VERSION' unless input_version + spatial_version, neo4j_version = *matching_version(input_version) + + install_path = "db/neo4j/#{args[:environment]}/plugins" + + if neo4j_version[0].to_i < 3 + file_name = "neo4j-spatial-#{spatial_version}-neo4j-#{neo4j_version}-server-plugin.zip" + system_or_fail("wget -O #{file_name} #{url}/#{spatial_version}-neo4j-#{neo4j_version}/#{file_name}?raw=true") + system_or_fail("unzip #{file_name} -d #{install_path}") + else + file_name = "neo4j-spatial-#{spatial_version}-neo4j-#{neo4j_version}-server-plugin.jar" + system_or_fail("wget -O #{file_name} #{url}/#{spatial_version}-neo4j-#{neo4j_version}/#{file_name}?raw=true") + system_or_fail("mv #{file_name} #{install_path}") + end + end +end diff --git a/lib/neo4jrb_spatial/version.rb b/lib/neo4jrb_spatial/version.rb index 2c62129..2275c35 100644 --- a/lib/neo4jrb_spatial/version.rb +++ b/lib/neo4jrb_spatial/version.rb @@ -1,3 +1,3 @@ module Neo4jrbSpatial - VERSION = '1.2.0' + VERSION = '1.2.0'.freeze end diff --git a/neo4jrb_spatial.gemspec b/neo4jrb_spatial.gemspec index 043e556..17486fc 100644 --- a/neo4jrb_spatial.gemspec +++ b/neo4jrb_spatial.gemspec @@ -22,7 +22,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rspec' spec.add_development_dependency 'pry' - spec.add_dependency 'neo4j', '>= 5.0.1', '< 8' - spec.add_dependency 'neo4j-core', '>= 5.0.1', '< 7' + spec.add_dependency 'neo4j', '>= 8.0.6', '<= 8.0.15' + spec.add_dependency 'neo4j-core', '>= 7', '< 7.1.0' spec.add_dependency 'neo4j-rake_tasks', '~> 0.3' end diff --git a/spec/active_node_spatial_spec.rb b/spec/active_node_spatial_spec.rb new file mode 100644 index 0000000..3193b5e --- /dev/null +++ b/spec/active_node_spatial_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' + +describe Neo4j::ActiveNode::Spatial do + let(:neo) { current_session } + let(:node) { Restaurant.create(name: "Chris's Restauarant", lat: 60.1, lon: 15.2) } + let(:outside_node) { Restaurant.create(name: 'Lily Thai', lat: 59.0, lon: 14.9) } + + class Restaurant + include Neo4j::ActiveNode + include Neo4j::ActiveNode::Spatial + + spatial_layer 'restaurants' # , type: 'SimplePoint', config: 'lon:lat' + property :name + property :lat + property :lon + end + + before do + Restaurant.delete_all + Restaurant.create_layer + [node, outside_node].each(&:add_to_spatial_layer) + end + + after do + Restaurant.remove_layer + end + + # let(:match) { Restaurant.all.spatial_match(:r, 'withinDistance:[60.0,15.0,100.0]') } + describe '#within_distance' do + let(:match) { Restaurant.within_distance({lat: 60.0, lon: 15.0}, 100.0) } + + it 'is a QueryProxy' do + expect(match).to respond_to(:to_cypher) + end + + it 'returns nodes within the given distance of the point' do + expect(match.first).to eq node + end + + it 'only returns expected nodes' do + expect(match.to_a).not_to include(outside_node) + end + end + + describe '#bbox' do + let(:min) { {lat: 59.9, lon: 14.9} } + let(:max) { {lat: 60.2, lon: 15.3} } + let(:match) { Restaurant.bbox(min, max) } + + it 'returns nodes that are inside the given bbox' do + nodes = match.to_a + expect(nodes.count).to eq(1) + expect(nodes.first).to eq(node) + end + + it 'is chainable' do + new_match = match.within_distance({lat: 60.0, lon: 15.0}, 100.0) + + expect(new_match).to respond_to(:to_cypher) + expect(new_match.first).to eq(node) + end + end + + # describe '#closest' do + # # this point is closer to outside_node + # # let(:coordinate) { {lat: 59.1, lon: 15.0} } + # # let(:coordinate) { {lat: 60.0, lon: 15.1} } + # let(:match) { Restaurant.closest(coordinate) } + # + # it 'returns the closest node first' do + # puts match.to_a + # # expect(match.count).to eq(1) + # expect(match.first).to eq(outside_node) + # end + # end + + describe '#intersects' do + let(:geom) { 'POLYGON ((15.3 60.1, 15.3 58.9, 14.8 58.9, 14.8 60.1, 15.3 60.1))' } + let(:match) { Restaurant.intersects(geom) } + + it 'returns node that intersect the given geometry' do + expect(match.count).to eq(2) + + expect(match).to include(node) + expect(match).to include(outside_node) + end + end +end diff --git a/spec/neo4jrb_spatial_spec.rb b/spec/neo4jrb_spatial_spec.rb index 57fb696..60ee57a 100644 --- a/spec/neo4jrb_spatial_spec.rb +++ b/spec/neo4jrb_spatial_spec.rb @@ -1,199 +1,220 @@ require 'spec_helper' -describe Neo4j::Server::Spatial do - let(:neo) { Neo4j::Session.current } +describe Neo4j::Core::Spatial do + let(:neo) { current_session } describe 'find the spatial plugin' do - it 'can get a description of the spatial plugin' do - si = neo.spatial_plugin - expect(si).not_to be_nil - expect(si[:graphdb][:addEditableLayer]).not_to be_nil + it 'can get a list of the spatial plugin procedures' do + expect(neo.spatial?).to eq(true) + + procedures = neo.spatial_procedures + expect(procedures).not_to be_nil + expect(procedures).to include('spatial.addWKTLayer') end end - describe 'add a point layer' do - it 'can add a simple point layer' do - pl = neo.add_point_layer('restaurants') - expect(pl).not_to be_nil - expect(pl.first[:data][:layer]).to eq('restaurants') - expect(pl.first[:data][:geomencoder_config]).to eq('lon:lat') + describe 'adding a layer (and removing)' do + describe '#add_layer' do + it 'works when passed SimplePoint type' do + layer = neo.add_layer('simple_layer', 'SimplePoint').first + + expect(layer.props[:layer]).to eq('simple_layer') + expect(layer.props[:layer_class]).to match('SimplePointLayer') + expect(layer.props[:geomencoder]).to match('SimplePointEncoder') + expect(layer.props[:geomencoder_config]).to eq('lon:lat') + + neo.remove_layer('simple_layer') + end + + it 'works when passed different lat and lon configs' do + layer = neo.add_layer('simple_layer_config', 'SimplePoint', 'attitude', 'fortitude').first + + expect(layer.props[:layer]).to eq('simple_layer_config') + expect(layer.props[:layer_class]).to match('SimplePointLayer') + expect(layer.props[:geomencoder]).to match('SimplePointEncoder') + expect(layer.props[:geomencoder_config]).to eq('fortitude:attitude') + + neo.remove_layer('simple_layer_config') + end + + it 'works when passed WKT type' do + layer = neo.add_layer('wkt_layer', 'WKT').first + + expect(layer.props[:layer]).to eq('wkt_layer') + expect(layer.props[:layer_class]).to match('EditableLayerImpl') + expect(layer.props[:geomencoder]).to match('WKTGeometryEncoder') + expect(layer.props[:geomencoder_config]).to eq('lon:lat') + + neo.remove_layer('wkt_layer') + end + + it 'works when passed WKB type' do + layer = neo.add_layer('wkb_layer', 'WKB').first + + expect(layer.props[:layer]).to eq('wkb_layer') + expect(layer.props[:layer_class]).to match('EditableLayerImpl') + expect(layer.props[:geomencoder]).to match('WKBGeometryEncoder') + expect(layer.props[:geomencoder_config]).to eq('lon:lat') + + neo.remove_layer('wkb_layer') + end end - it 'can add a simple point layer with lat and long' do - pl = neo.add_point_layer('coffee_shops', 'latitude', 'longitude') - expect(pl).not_to be_nil - expect(pl.first[:data][:layer]).to eq('coffee_shops') - expect(pl.first[:data][:geomencoder_config]).to eq('longitude:latitude') + describe '#add_point_layer' do + it 'can add a simple point layer' do + response = neo.add_point_layer('restaurants').first + + expect(response.props[:layer]).to eq('restaurants') + neo.remove_layer('restaurants') + end end - end - describe 'add an editable layer' do - it 'can add an editable layer' do - el = neo.add_editable_layer('zipcodes', 'WKT', 'wkt') - expect(el).not_to be_nil - expect(el.first[:data][:layer]).to eq('zipcodes') - expect(el.first[:data][:geomencoder_config]).to eq('wkt') + describe '#add_wkt_layer' do + it 'can add a wkt layer' do + response = neo.add_wkt_layer('zipcodes', 'zone_area') + el = response.first + + expect(el).not_to be_nil + expect(el.props[:layer]).to eq('zipcodes') + expect(el.props[:geomencoder_config]).to eq('zone_area') + + neo.remove_layer('zipcodes') + end end end + describe 'get a spatial layer' do it 'can get a layer' do - sl = neo.get_layer('restaurants') - expect(sl).not_to be_nil - expect(sl.first[:data][:layer]).to eq('restaurants') - end - end + neo.add_point_layer('restaurants') - describe 'create a spatial index' do - it 'can create a spatial index' do - index = neo.create_spatial_index('restaurants') - expect(index[:provider]).to eq('spatial') - expect(index[:geometry_type]).to eq('point') - expect(index[:lat]).to eq('lat') - expect(index[:lon]).to eq('lon') - end + layer = neo.get_layer('restaurants').first + expect(layer).not_to be_nil - it 'fails when passing an invalid name' do - expect { neo.create_spatial_index('') }.to raise_error Neo4jrbSpatial::RequestError, /not be empty/ + expect(layer.props[:layer]).to eq('restaurants') + + neo.remove_layer('restaurants') end end describe 'add geometry to spatial layer' do it 'can add a geometry' do + neo.add_wkt_layer('zipcodes') + geometry = 'LINESTRING (15.2 60.1, 15.3 60.1)' - geo = neo.add_geometry_to_layer('zipcodes', geometry) + geo = neo.add_wkt('zipcodes', geometry) expect(geo).not_to be_nil - expect(geo.first[:data][:wkt]).to eq(geometry) + expect(geo.first.props[:wkt]).to eq(geometry) + + neo.remove_layer('zipcodes') end end describe 'update geometry from spatial layer' do it 'can update a geometry' do + neo.add_wkt_layer('zipcodes') + geometry = 'LINESTRING (15.2 60.1, 15.3 60.1)' - geo = neo.add_geometry_to_layer('zipcodes', geometry) + geo = neo.add_wkt('zipcodes', geometry) expect(geo).not_to be_nil - expect(geo.first[:data][:wkt]).to eq(geometry) + expect(geo.first.props[:wkt]).to eq(geometry) geometry = 'LINESTRING (14.7 60.1, 15.3 60.1)' - existing_geo = neo.edit_geometry_from_layer('zipcodes', geometry, geo) - expect(existing_geo.first[:data][:wkt]).to eq(geometry) - expect(existing_geo.first[:self].split('/').last.to_i).to eq(geo.first[:self].split('/').last.to_i) + existing_geo = neo.update_from_wkt('zipcodes', geometry, geo) + expect(existing_geo.first.props[:wkt]).to eq(geometry) + expect(existing_geo.first.neo_id.to_i).to eq(geo.first.neo_id.to_i) + + neo.remove_layer('zipcodes') end end - describe 'add a node to a layer' do - it 'can add a node to a simple point layer' do + describe '#add_node_to_layer' do + it 'works' do + neo.add_layer('restaurants') properties = {name: "Max's Restaurant", lat: 41.8819, lon: 87.6278} - node = Neo4j::Node.create(properties, :Restaurant) + node_query = Neo4j::Core::Query.new(session: neo).create(n: {Restaurant: properties}).return(:n) + node = neo.query(node_query).first.n + expect(node).not_to be_nil added = neo.add_node_to_layer('restaurants', node) - expect(added.first[:data][:lat]).to eq(properties[:lat]) - expect(added.first[:data][:lon]).to eq(properties[:lon]) + expect(added.first.props[:lat]).to eq(properties[:lat]) + expect(added.first.props[:lon]).to eq(properties[:lon]) - added = neo.add_node_to_spatial_index('restaurants', node) - expect(added[:data][:lat]).to eq(properties[:lat]) - expect(added[:data][:lon]).to eq(properties[:lon]) + neo.remove_layer('restaurants') end end - describe 'find geometries in a bounding box' do - it 'can find a geometry in a bounding box' do - properties = {name: "Max's Restaurant", lat: 41.8819, lon: 87.6278} - nodes = neo.find_geometries_in_bbox('restaurants', 87.5, 87.7, 41.7, 41.9) - expect(nodes).not_to be_empty - result = nodes.find { |node| node[:data][:name] == "Max's Restaurant" } - expect(result[:data][:lat]).to eq(properties[:lat]) - expect(result[:data][:lon]).to eq(properties[:lon]) - end + describe 'spatial matching queries' do + let(:properties) { {name: "Max's Restaurant", lat: 41.8819, lon: 87.6278} } + let(:node_query) { Neo4j::Core::Query.new(session: neo).create(n: {Restaurant: properties}).return(:n) } - it 'can find a geometry in a bounding box using cypher' do - properties = {lat: 60.1, lon: 15.2} - neo.create_spatial_index('geombbcypher', 'point', 'lat', 'lon') - node = Neo4j::Node.create(properties, :dummy) - neo.add_node_to_spatial_index('geombbcypher', node) - existing_node = neo.query.start("node = node:geombbcypher('bbox:[15.0,15.3,60.0,60.2]')").pluck(:node).first - expect(existing_node).not_to be_nil - expect(existing_node.props[:lat]).to eq(properties[:lat]) - expect(existing_node.props[:lon]).to eq(properties[:lon]) + before do + neo.add_layer('restaurants') + node = neo.query(node_query).first.n + neo.add_node_to_layer('restaurants', node) end - it 'can find a geometry in a bounding box using cypher two' do - properties = {lat: 60.1, lon: 15.2} - neo.create_spatial_index('geombbcypher2', 'point', 'lat', 'lon') - node = Neo4j::Node.create(properties) - neo.add_node_to_spatial_index('geombbcypher2', node) - existing_node = node.query.start("node = node:geombbcypher2('bbox:[15.0,15.3,60.0,60.2]')").pluck(:node).first - expect(existing_node).not_to be_nil - expect(existing_node.props[:lat]).to eq(properties[:lat]) - expect(existing_node.props[:lon]).to eq(properties[:lon]) + after do + neo.remove_layer('restaurants') end - it 'fails when passing an invalid node' do - expect { neo.add_node_to_spatial_index('geombbcypher', nil) }.to raise_error Neo4jrbSpatial::RequestError, /For input string/ - end - end + describe '#bbox (#find_geometries_in_bbox)' do + it 'can find a geometry in a bounding box' do + min = {lon: 87.5, lat: 41.7} + max = {lon: 87.7, lat: 41.9} - describe 'find geometries within distance' do - it 'can find a geometry within distance' do - properties = {name: "Max's Restaurant", lat: 41.8819, lon: 87.6278} - nodes = neo.find_geometries_within_distance('restaurants', 87.627, 41.881, 10) - expect(nodes).not_to be_empty - result = nodes.find { |node| node[:data][:name] == "Max's Restaurant" } - expect(result[:data][:lat]).to eq(properties[:lat]) - expect(result[:data][:lon]).to eq(properties[:lon]) - end + nodes = neo.find_geometries_in_bbox('restaurants', min, max) + expect(nodes).not_to be_empty - it 'can find a geometry within distance using cypher' do - properties = {lat: 60.1, lon: 15.2} - neo.create_spatial_index('geowdcypher', 'point', 'lat', 'lon') - node = Neo4j::Node.create(properties) - neo.add_node_to_spatial_index('geowdcypher', node) - existing_node = neo.query.start('n = node:geowdcypher({bbox})').params(bbox: 'withinDistance:[60.0,15.0,100.0]').pluck(:n).first - expect(existing_node).not_to be_nil - expect(existing_node.props[:lat]).to eq(properties[:lat]) - expect(existing_node.props[:lon]).to eq(properties[:lon]) + result = nodes.find { |n| n.props[:name] == "Max's Restaurant" } + expect(result.props[:lat]).to eq(properties[:lat]) + expect(result.props[:lon]).to eq(properties[:lon]) + end end - it 'can find a geometry within distance using cypher 2' do - properties = {lat: 60.1, lon: 15.2} - neo.create_spatial_index('geowdcypher2', 'point', 'lat', 'lon') - node = Neo4j::Node.create(properties) - neo.add_node_to_spatial_index('geowdcypher2', node) - existing_node = neo.query.start('n = node:geowdcypher2({bbox})').params(bbox: 'withinDistance:[60.0,15.0,100.0]').pluck(:n).first - expect(existing_node).not_to be_nil - expect(existing_node.props[:lat]).to eq(properties[:lat]) - expect(existing_node.props[:lon]).to eq(properties[:lon]) - end - end + describe '#within_distance (#find_geometries_within_distance)' do + it 'can find a geometry within distance' do + nodes = neo.find_geometries_within_distance('restaurants', {lon: 87.627, lat: 41.881}, 10) + expect(nodes).not_to be_empty - describe 'ActiveNode integration' do - let(:node) { Restaurant.create(name: "Chris's Restauarant", lat: 60.1, lon: 15.2) } - let(:outside_node) { Restaurant.create(name: 'Lily Thai', lat: 59.0, lon: 14.9) } - before do - stub_const('Restaurant', Class.new do - include Neo4j::ActiveNode - include Neo4j::ActiveNode::Spatial - spatial_index 'restaurants' - property :name - property :lat - property :lon - end) - - Restaurant.delete_all - [node, outside_node].each(&:add_to_spatial_index) + result = nodes.find { |n| n.props[:name] == "Max's Restaurant" } + expect(result.props[:lat]).to eq(properties[:lat]) + expect(result.props[:lon]).to eq(properties[:lon]) + end end - let(:match) { Restaurant.all.spatial_match(:r, 'withinDistance:[60.0,15.0,100.0]') } + describe '#intersects' do + it 'returns nodes that intersect the given geometry' do + geom = 'POLYGON ((87.5 41.7, 87.5 41.9, 87.7 41.9, 87.7 41.7, 87.5 41.7))' + nodes = neo.intersects('restaurants', geom) + expect(nodes.count).to eq(1) - it 'is a QueryProxy' do - expect(match).to respond_to(:to_cypher) + expect(nodes.first.props[:name]).to eq("Max's Restaurant") + end end - it 'matches to the node in the spatial index' do - expect(match.first).to eq node - end + describe '#closest' do + # TODO: poor test + it 'returns the closest node to the given coordinate' do + other_properties = {name: "Min's Restaurant", lat: 41.87, lon: 87.6} + other_node_query = Neo4j::Core::Query.new(session: neo).create(n: {Restaurant: other_properties}).return(:n) + neo.query(other_node_query).first.n - it 'only returns expected nodes' do - expect(match.to_a).not_to include(outside_node) + coordinate = {lat: 41.89, lon: 87.63} + + closest = neo.closest('restaurants', coordinate).first + expect(closest.props[:name]).to eq(properties[:name]) + end end end + + # TODO: find small shapefile to do this with. + # describe 'importing shapefile' do + # it 'works' do + # layer = neo.add_editable_layer('cities', 'WKT', 'wkt') + # filepath = "#{File.dirname(__FILE__)}/../cities/Cities2015.shp" + # + # abc = neo.import_shapefile_to_layer('cities', filepath) + # binding.pry + # end + # end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ed91921..985aefb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,37 +1,37 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'rspec' +require 'pry' require 'json' require 'neo4j-core' require 'neo4j' require 'neo4jrb_spatial' +require 'neo4j/core/cypher_session/adaptors/http' def server_url ENV['NEO4J_URL'] || 'http://localhost:7474' end -def create_server_session - Neo4j::Session.open(:server_db, server_url) +def current_session + @current_session ||= begin + neo4j_adaptor = Neo4j::Core::CypherSession::Adaptors::HTTP.new(server_url) + session = Neo4j::Core::CypherSession.new(neo4j_adaptor) + Neo4j::ActiveBase.current_session = session + end end -def clear_model_memory_caches - Neo4j::ActiveRel::Types::WRAPPED_CLASSES.clear - Neo4j::ActiveNode::Labels::WRAPPED_CLASSES.clear - Neo4j::ActiveNode::Labels.clear_wrapped_models +def create_db_constraint + Neo4j::ActiveBase.label_object(:Restaurant).create_constraint(:uuid, type: :unique) end RSpec.configure do |c| c.before(:suite) do - create_server_session - Neo4j::Session.current.query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n, r') + current_session.query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n, r') + create_db_constraint end c.before do - curr_session = Neo4j::Session.current - curr_session.close if curr_session && !curr_session.is_a?(Neo4j::Server::CypherSession) - Neo4j::Session.current || create_server_session end c.after(:each) do - clear_model_memory_caches end end