From ead47a6d0d019f2341945d5bf044dc3a122ab43a Mon Sep 17 00:00:00 2001 From: Elad Ossadon Date: Tue, 8 Jan 2013 02:32:46 -0800 Subject: [PATCH] v0.1 release * Added batch support, for much faster intiialization of current DB or reindexing all DB. * Dropped indexes per model, instead, using `node_auto_index` and `relationship_auto_index`, letting Neo4j auto index objects. * One `neo_save` method instead of `neo_create` and `neo_update`. It takes care of inserting or updating. --- CHANGELOG.md | 35 ++++++ README.md | 195 ++++++++++++++++++++++------- TODO.md | 2 - lib/neoid.rb | 103 +++++++++++++-- lib/neoid/batch.rb | 168 +++++++++++++++++++++++++ lib/neoid/config.rb | 6 + lib/neoid/middleware.rb | 6 +- lib/neoid/model_additions.rb | 75 ++++++++--- lib/neoid/model_config.rb | 2 + lib/neoid/node.rb | 157 +++++++++++++++-------- lib/neoid/relationship.rb | 141 +++++++++++++-------- lib/neoid/version.rb | 2 +- spec/neoid/batch_spec.rb | 170 +++++++++++++++++++++++++ spec/neoid/config_spec.rb | 13 ++ spec/neoid/model_additions_spec.rb | 128 ++++++++++++++++--- spec/neoid/search_spec.rb | 1 - spec/neoid_spec.rb | 8 +- spec/spec_helper.rb | 20 +-- spec/support/models.rb | 15 ++- spec/support/schema.rb | 4 + 20 files changed, 1033 insertions(+), 218 deletions(-) create mode 100644 lib/neoid/batch.rb create mode 100644 lib/neoid/config.rb create mode 100644 spec/neoid/batch_spec.rb create mode 100644 spec/neoid/config_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3417cf5..ac5e754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## v0.1 + +* Added batch support, for much faster intiialization of current DB or reindexing all DB. +* Dropped indexes per model, instead, using `node_auto_index` and `relationship_auto_index`, letting Neo4j auto index objects. +* One `neo_save` method instead of `neo_create` and `neo_update`. It takes care of inserting or updating. + +### Breaking changes: + +Model indexes (such as `users_index`) are now turned off by default. Instead, Neoid uses Neo4j's auto indexing feature. + +In order to have the model indexes back, use this in your configuration: + +```ruby +Neoid.configure do |c| + c.enable_per_model_indexes = true +end +``` + +This will turn on for all models. + +You can turn off for a specific model with: + +```ruby +class User < ActiveRecord::Base + include Neoid::Node + + neoidable enable_model_index: false do |c| + end +end +``` + +## v0.0.51 + +* Releasing Neoid as a gem. + ## v0.0.41 * fixed really annoying bug caused by Rails design -- Rails doesn't call `after_destroy` when assigning many to many relationships to a model, like `user.movies = [m1, m2, m3]` or `user.update_attributes(params[:user])` where it contains `params[:user][:movie_ids]` list (say from checkboxes), but it DOES CALL after_create for the new relationships. the fix adds after_remove callback to the has_many relationships, ensuring neo4j is up to date with all changes, no matter how they were committed diff --git a/README.md b/README.md index 2f2cb68..e7dc9f8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![Build Status](https://secure.travis-ci.org/elado/neoid.png)](http://travis-ci.org/elado/neoid) - Make your ActiveRecords stored and searchable on Neo4j graph database, in order to make fast graph queries that MySQL would crawl while doing them. Neoid to Neo4j is like Sunspot to Solr. You get the benefits of Neo4j speed while keeping your schema on your plain old RDBMS. @@ -12,6 +11,11 @@ Neoid doesn't require JRuby. It's based on the great [Neography](https://github. Neoid offers querying Neo4j for IDs of objects and then fetch them from your RDBMS, or storing all desired data on Neo4j. +**Important: Heroku Support is not available because Herokud doesn't support Gremlin. So until further notice, easiest way is to self host a Neo4j on EC2 in the same zone, and connect from your dyno to it** + +## Changelog + +[See Changelog](https://github.com/elado/neoid/blob/master/CHANGELOG.md) ## Installation @@ -19,11 +23,9 @@ Neoid offers querying Neo4j for IDs of objects and then fetch them from your RDB Add to your Gemfile and run the `bundle` command to install it. ```ruby -gem 'neoid', '~> 0.0.51' +gem 'neoid', '~> 0.1' ``` -Future versions may have breaking changes but will arrive with migration code. - **Requires Ruby 1.9.2 or later.** ## Usage @@ -51,6 +53,11 @@ Neography.configure do |c| end Neoid.db = $neo + +Neoid.configure do |c| + # should Neoid create sub-reference from the ref node (id#0) to every node-model? default: true + c.enable_subrefs = true +end ``` `01_` in the file name is in order to get this file loaded first, before the models (initializers are loaded alphabetically). @@ -71,9 +78,9 @@ class User < ActiveRecord::Base end ``` -This will help to create a corresponding node on Neo4j when a user is created, delete it when a user is destroyed, and update it if needed. +This will help to create/update/destroy a corresponding node on Neo4j when changed are made a User model. -Then, you can customize what fields will be saved on the node in Neo4j, inside neoidable configuration: +Then, you can customize what fields will be saved on the node in Neo4j, inside `neoidable` configuration, using `field`. You can also pass blocks to save content that's not a real column: ```ruby class User < ActiveRecord::Base @@ -89,7 +96,6 @@ class User < ActiveRecord::Base end ``` - #### Relationships Let's assume that a `User` can `Like` `Movie`s: @@ -151,7 +157,7 @@ class Like < ActiveRecord::Base end ``` -Neoid adds `neo_node` and `neo_relationships` to nodes and relationships, respectively. +Neoid adds the metohds `neo_node` and `neo_relationships` to instances of nodes and relationships, respectively. So you could do: @@ -169,38 +175,52 @@ rel.end_node # user.movies.first.neo_node rel.rel_type # 'likes' ``` -## Index for Full-Text Search +#### Disabling auto saving to Neo4j: -Using `search` block inside a `neoidable` block, you can store certain fields. +If you'd like to save nodes manually rather than after_save, use `auto_index: false`: ```ruby -# movie.rb - -class Movie < ActiveRecord::Base +class User < ActiveRecord::Base include Neoid::Node - - neoidable do |c| - c.field :slug - c.field :name - - c.search do |s| - # full-text index fields - s.fulltext :name - s.fulltext :description - - # just index for exact matches - s.index :year - end + + neoidable auto_index: false do |c| end end -``` -Records will be automatically indexed when inserted or updated. +user = User.create!(name: "Elad") # no node is created in Neo4j! + +user.neo_save # now there is! +``` ## Querying You can query with all [Neography](https://github.com/maxdemarzi/neography)'s API: `traverse`, `execute_query` for Cypher, and `execute_script` for Gremlin. +### Basics: + +#### Finding a node by ID + +Nodes and relationships are auto indexed in the `node_auto_index` and `relationship_auto_index` indexes, where the key is `Neoid::UNIQUE_ID_KEY` (which is 'neoid_unique_id') and the value is a combination of the class name and model id, `Movie:43`, this value is accessible with `model.neo_unique_id`. So use the constant and this method, never rely on assebling those values on your own because they might change in the future. + +That means, you can query like this: + +```ruby +Neoid.db.get_node_auto_index(Neoid::UNIQUE_ID_KEY, user.neo_unique_id) +# => returns a Neography hash + +Neoid::Node.from_hash(Neoid.db.get_node_auto_index(Neoid::UNIQUE_ID_KEY, user.neo_unique_id)) +# => returns a Neography::Node +``` + +#### Finding all nodes of type + +If Subreferences are enabled, you can get the subref node and then get all attached nodes: + +```ruby +Neoid.ref_node.outgoing('users_subref').first.outgoing('users_subref').to_a +# => this, according to Neography, returns an array of Neography::Node so no conversion is needed +``` + ### Gremlin Example: These examples query Neo4j using Gremlin for IDs of objects, and then fetches them from ActiveRecord with an `in` query. @@ -208,7 +228,7 @@ These examples query Neo4j using Gremlin for IDs of objects, and then fetches th Of course, you can store using the `neoidable do |c| c.field ... end` all the data you need in Neo4j and avoid querying ActiveRecord. -**Most popular categories** +**Most liked movies** ```ruby gremlin_query = <<-GREMLIN @@ -228,15 +248,18 @@ movie_ids = Neoid.db.execute_script(gremlin_query) Movie.where(id: movie_ids) ``` -Assuming we have another `Friendship` model which is a relationship with start/end nodes of `user` and type of `friends`, +*Side note: the resulted movies won't be sorted by like count because the RDBMS won't necessarily do it as we passed a list of IDs. You can sort it yourself with array manipulation, since you have the ids.* + **Movies of user friends that the user doesn't have** +Let's assume we have another `Friendship` model which is a relationship with start/end nodes of `user` and type of `friends`, + ```ruby user = User.find(1) gremlin_query = <<-GREMLIN - u = g.idx('users_index')[[ar_id:user_id]].next() + u = g.idx('node_auto_index').get(unique_id_key, user_unique_id).next() movies = [] u @@ -246,15 +269,42 @@ gremlin_query = <<-GREMLIN .except(movies).collect{it.ar_id} GREMLIN -movie_ids = Neoid.db.execute_script(gremlin_query, user_id: user.id) +movie_ids = Neoid.db.execute_script(gremlin_query, unique_id_key: Neoid::UNIQUE_ID_KEY, user_unique_id: user.neo_unique_id) Movie.where(id: movie_ids) ``` -`.next()` is in order to get a vertex object which we can actually query on. +## Full Text Search +### Index for Full-Text Search -### Full Text Search +Using `search` block inside a `neoidable` block, you can store certain fields. + +```ruby +# movie.rb + +class Movie < ActiveRecord::Base + include Neoid::Node + + neoidable do |c| + c.field :slug + c.field :name + + c.search do |s| + # full-text index fields + s.fulltext :name + s.fulltext :description + + # just index for exact matches + s.index :year + end + end +end +``` + +Records will be automatically indexed when inserted or updated. + +### Querying a Full-Text Search index ```ruby # will match all movies with full-text match for name/description. returns ActiveRecord instanced @@ -270,14 +320,63 @@ Neoid.neo_search([Movie, User], "hello") Movie.neo_search(year: 2013).results ``` +Full text search with Neoid is very limited and is likely not to develop more than this basic functionality. I strongly recommend using gems like Sunspot over Solr. + +## Batches + +Neoid has a batch ability, that is good for mass updateing/inserting of nodes/relationships. It sends batched requests to Neography, and takes care of type conversion (neography batch returns hashes and other primitive types) and "after" actions (via promises). + +A few examples, easy to complex: + +```ruby +Neoid.batch(batch_size: 100) do + User.all.each(&:neo_save) +end +``` +With `then`: + +```ruby +User.first.name # => "Elad" + +Neoid.batch(batch_size: 100) do + User.all.each(&:neo_save) +end.then do |results| + # results is an array of the script results from neo4j REST. + + results[0].name # => "Elad" +end +``` + +*Nodes and relationships in the results are automatically converted to Neography::Node and Neography::Relationship, respectively.* + +With individual `then` as well as `then` for the entire batch: + +```ruby +Neoid.batch(batch_size: 30) do |batch| + (1..90).each do |i| + (batch << [:create_node, { name: "Hello #{i}" }]).then { |result| puts result.name } + end +end.then do |results| + puts results.collect(&:name) +end +``` + +When in a batch, `neo_save` adds gremlin scripts to a batch, instead of running them immediately. The batch flushes whenever the `batch_size` option is met. +So even if you have 20000 users, Neoid will insert/update in smaller batches. Default `batch_size` is 200. + + ## Inserting records of existing app -If you have an existing database and just want to integrate Neoid, configure the `neoidable`s and run in a rake task or console +If you have an existing database and just want to integrate Neoid, configure the `neoidable`s and run in a rake task or console. + +Use batches! It's free, and much faster. Also, you should use `includes` to incude the relationship edges on relationship entities, so it doesn't query the DB on each relationship. ```ruby -[ Like.includes(:user).includes(:movie), OtherRelationshipModel ].each { |model| model.all.each(&:neo_update) } +Neoid.batch do + [ Like.includes(:user).includes(:movie), OtherRelationshipModel.includes(:from_model).includes(:to_model) ].each { |model| model.all.each(&:neo_save) } -NodeModel.all.each(&:neo_update) + NodeModel.all.each(&:neo_save) +end ``` This will loop through all of your relationship records and generate the two edge nodes along with a relationship (eager loading for better performance). @@ -289,30 +388,32 @@ Better interface for that in the future. ## Behind The Scenes -Whenever the `neo_node` on nodes or `neo_relationship` on relationships is called, Neoid checks if there's a corresponding node/relationship in Neo4j. If not, it does the following: +Whenever the `neo_node` on nodes or `neo_relationship` on relationships is called, Neoid checks if there's a corresponding node/relationship in Neo4j (with the auto indexes). If not, it does the following: ### For Nodes: -1. Ensures there's a sub reference node (read [here](http://docs.neo4j.org/chunked/stable/tutorials-java-embedded-index.html) about sub reference nodes) +1. Ensures there's a sub reference node (read [here](http://docs.neo4j.org/chunked/stable/tutorials-java-embedded-index.html) about sub references), if that option is on. 2. Creates a node based on the ActiveRecord, with the `id` attribute and all other attributes from `neoidable`'s field list 3. Creates a relationship between the sub reference node and the newly created node -4. Adds the ActiveRecord `id` to a node index, pointing to the Neo4j node id, for fast lookup in the future +4. Auto indexes a node in the auto index, for fast lookup in the future -Then, when it needs to find it again, it just seeks the node index with that ActiveRecord id for its neo node id. +Then, when it needs to find it again, it just seeks the auto index with that ActiveRecord id. ### For Relationships: -Like Nodes, it uses an index (relationship index) to look up a relationship by ActiveRecord id +Like Nodes, it uses an auto index, to look up a relationship by ActiveRecord id 1. With the options passed in the `neoidable`, it fetches the `start_node` and `end_node` 2. Then, it calls `neo_node` on both, in order to create the Neo4j nodes if they're not created yet, and creates the relationship with the type from the options. -3. Add the relationship to the relationship index. +3. Adds the relationship to the relationship index. ## Testing In order to test your app or this gem, you need a running Neo4j database, dedicated to tests. -I use port 7574 for this. To run another database locally: +I use port 7574 for testing. + +To run another database locally (read [here](http://docs.neo4j.org/chunked/1.9.M03/server-installation.html#_multiple_server_instances_on_one_machine) too): Copy the entire Neo4j database folder to a different location, @@ -344,7 +445,7 @@ end ## Testing This Gem -Just run `rake` from the gem folder. +Run the Neo4j DB on port 7574, and run `rake` from the gem folder. ## Contributing @@ -356,9 +457,9 @@ Please create a [new issue](https://github.com/elado/neoid/issues) if you run in Unfortunately, as for now, Neo4j add-on on Heroku doesn't support Gremlin. Therefore, this gem won't work on Heroku's add on. You should self-host a Neo4j instance on an EC2 or any other server. -## To Do +## TO DO -[To Do](https://github.com/elado/neoid/blob/master/TODO.md) +[TO DO](HTTPS://GITHUB.COM/ELADO/NEOID/BLOB/MASTER/TODO.MD) --- diff --git a/TODO.md b/TODO.md index 073b882..41c19be 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,4 @@ # Neoid - To Do -* Allow to disable sub reference nodes through options * Execute queries/scripts from model and not Neography (e.g. `Movie.neo_gremlin(gremlin_query)` with query that outputs IDs, returns a list of `Movie`s) * Rake task to index all nodes and relatiohsips in Neo4j -* Test update node diff --git a/lib/neoid.rb b/lib/neoid.rb index ccf5ace..c15f321 100644 --- a/lib/neoid.rb +++ b/lib/neoid.rb @@ -1,24 +1,27 @@ +require 'neography' require 'neoid/version' +require 'neoid/config' require 'neoid/model_config' require 'neoid/model_additions' require 'neoid/search_session' require 'neoid/node' require 'neoid/relationship' +require 'neoid/batch' require 'neoid/database_cleaner' require 'neoid/railtie' if defined?(Rails) module Neoid - DEFAULT_FULLTEXT_SEARCH_INDEX_NAME = 'neoid_default_search_index' + DEFAULT_FULLTEXT_SEARCH_INDEX_NAME = :neoid_default_search_index + NODE_AUTO_INDEX_NAME = 'node_auto_index' + RELATIONSHIP_AUTO_INDEX_NAME = 'relationship_auto_index' + UNIQUE_ID_KEY = 'neoid_unique_id' class << self attr_accessor :db attr_accessor :logger attr_accessor :ref_node attr_accessor :env_loaded - - def models - @models ||= [] - end + attr_reader :config def node_models @node_models ||= [] @@ -29,20 +32,42 @@ def relationship_models end def config - @config ||= {} + @config ||= begin + c = Neoid::Config.new + + # default + c.enable_subrefs = true + c.enable_per_model_indexes = false + + c + end + end + + def configure + yield config end def initialize_all @env_loaded = true - relationship_models.each do |rel_model| - Relationship.initialize_relationship(rel_model) - end + logger.info "Neoid initialize_all" + initialize_relationships + initialize_server + end + + def initialize_server + initialize_auto_index + initialize_subrefs + initialize_per_model_indexes end def db raise "Must set Neoid.db with a Neography::Rest instance" unless @db @db end + + def batch(options={}, &block) + Neoid::Batch.new(options, &block).run + end def logger @logger ||= Logger.new(ENV['NEOID_LOG'] ? ENV['NEOID_LOG_FILE'] || $stdout : '/dev/null') @@ -53,10 +78,7 @@ def ref_node end def reset_cached_variables - Neoid.models.each do |klass| - klass.instance_variable_set(:@_neo_subref_node, nil) - end - $neo_ref_node = nil + initialize_subrefs end def clean_db(confirm) @@ -83,6 +105,19 @@ def use(flag=true) self.enabled = old end + def execute_script_or_add_to_batch(gremlin_query, script_vars) + if Neoid::Batch.current_batch + # returns a SingleResultPromiseProxy! + Neoid::Batch.current_batch << [:execute_script, gremlin_query, script_vars] + else + value = Neoid.db.execute_script(gremlin_query, script_vars) + + value = yield(value) if block_given? + + Neoid::BatchPromiseProxy.new(value) + end + end + # create a fulltext index if not exists def ensure_default_fulltext_search_index Neoid.db.create_node_index(DEFAULT_FULLTEXT_SEARCH_INDEX_NAME, 'fulltext', 'lucene') unless (indexes = Neoid.db.list_node_indexes) && indexes[DEFAULT_FULLTEXT_SEARCH_INDEX_NAME] @@ -155,5 +190,47 @@ def generate_field_query(field, term, fulltext = false) "(" + term.split(/\s+/).reject(&:empty?).map{ |t| "#{field}#{fulltext}:#{sanitize_term(t)}" }.join(" AND ") + ")" end + + def initialize_relationships + logger.info "Neoid initialize_relationships" + relationship_models.each do |rel_model| + Relationship.initialize_relationship(rel_model) + end + end + + def initialize_auto_index + logger.info "Neoid initialize_auto_index" + Neoid.db.set_node_auto_index_status(true) + Neoid.db.add_node_auto_index_property(UNIQUE_ID_KEY) + + Neoid.db.set_relationship_auto_index_status(true) + Neoid.db.add_relationship_auto_index_property(UNIQUE_ID_KEY) + end + + def initialize_subrefs + return unless config.enable_subrefs + + node_models.each do |klass| + klass.reset_neo_subref_node + end + + logger.info "Neoid initialize_subrefs" + batch do + node_models.each(&:neo_subref_node) + end.then do |results| + node_models.zip(results).each do |klass, subref| + klass.neo_subref_node = subref + end + end + end + + def initialize_per_model_indexes + return unless config.enable_per_model_indexes + + logger.info "Neoid initialize_subrefs" + batch do + node_models.each(&:neo_model_index) + end + end end end diff --git a/lib/neoid/batch.rb b/lib/neoid/batch.rb new file mode 100644 index 0000000..0301b19 --- /dev/null +++ b/lib/neoid/batch.rb @@ -0,0 +1,168 @@ +module Neoid + class Batch + def default_options=(value) + @default_options = value + end + + def self.default_options + @default_options ||= { batch_size: 200, individual_promises: true } + end + + def self.current_batch + Thread.current[:neoid_current_batch] + end + + def self.current_batch=(batch) + Thread.current[:neoid_current_batch] = batch + end + + def self.reset_current_batch + Thread.current[:neoid_current_batch] = nil + end + + def initialize(options={}, &block) + if options.respond_to?(:call) && !block + block = options + options = {} + end + + options.reverse_merge!(self.class.default_options) + + @options = options + @block = block + end + + def <<(command) + commands << command + + if commands.length >= @options[:batch_size] + flush_batch + end + + if @options[:individual_promises] + promise = SingleResultPromiseProxy.new(command) + thens << promise + promise + end + end + + def commands + @commands ||= [] + end + + def thens + @thens ||= [] + end + + def count + @commands ? @commands.count : 0 + end + + def results + @results ||= [] + end + + def run + self.class.current_batch = self + + begin + @block.call(self) + ensure + self.class.reset_current_batch + end + + Neoid.logger.info "Neoid batch (#{commands.length} commands)" + + flush_batch + + BatchPromiseProxy.new(results) + end + + private + def flush_batch + return [] if commands.empty? + current_results = nil + + # results = Neoid.db.batch(*commands).collect { |result| result['body'] } + + benchmark = Benchmark.measure { + current_results = Neoid.db.batch(*commands).collect { |result| result['body'] } + } + Neoid.logger.info "Neoid batch (#{commands.length} commands) - #{benchmark}" + commands.clear + + process_results(current_results) + + thens.zip(current_results).each { |t, result| t.perform(result) } + + thens.clear + + results.concat current_results + end + + def process_results(results) + results.map! do |result| + return result unless result.is_a?(Hash) && result['self'] && result['self'][%r[^https?://.*/(node|relationship)/\d+]] + + type = case $1 + when 'node' then Neoid::Node + when 'relationship' then Neoid::Relationship + else return result + end + + type.from_hash(result) + end + end + end + + # returned from a full batch, after it has been executed, + # so a `.then` can be chained after the batch do ... end + # it proxies all methods to the result + class BatchPromiseProxy + def initialize(*results) + @results = results + end + + def method_missing(method, *args) + @results.send(method, *args) + end + + def then + yield(*@results) + end + end + + # returned from adding (<<) an item to a batch in a batch block: + # Neoid.batch { |batch| (batch << [:neography_command, param]).is_a?(SingleResultPromiseProxy) } + # so a `.then` can be chained: + # Neoid.batch { |batch| (batch << [:neography_command, param]).then { |result| puts result } } + # the `then` is called once the batch is flushed with the result of the single job in the batch + # it proxies all methods to the result, so in case it is returned (like in Neoid.execute_script_or_add_to_batch) + # the result of the method will be proxied to the result from the batch. See Node#neo_save + class SingleResultPromiseProxy + def initialize(*args) + end + + attr_accessor :result + + def result + raise "Accessed result too soon" unless @result + @result + end + + def method_missing(method, *args) + result.send(method, *args) + end + + def then(&block) + @then = block + self + end + + def perform(result) + @result = result + return unless @then + @then.call(result) + end + end +end \ No newline at end of file diff --git a/lib/neoid/config.rb b/lib/neoid/config.rb new file mode 100644 index 0000000..c415062 --- /dev/null +++ b/lib/neoid/config.rb @@ -0,0 +1,6 @@ +module Neoid + class Config + attr_accessor :enable_subrefs + attr_accessor :enable_per_model_indexes + end +end diff --git a/lib/neoid/middleware.rb b/lib/neoid/middleware.rb index a0c53a6..858c834 100644 --- a/lib/neoid/middleware.rb +++ b/lib/neoid/middleware.rb @@ -5,10 +5,12 @@ def initialize(app) end def call(env) - old, Thread.current[:neoid_enabled] = Thread.current[:neoid_enabled], true + old_enabled, Thread.current[:neoid_enabled] = Thread.current[:neoid_enabled], true + old_batch, Thread.current[:neoid_current_batch] = Thread.current[:neoid_current_batch], nil @app.call(env) ensure - Thread.current[:neoid_enabled] = old + Thread.current[:neoid_enabled] = old_enabled + Thread.current[:neoid_current_batch] = old_batch end end end diff --git a/lib/neoid/model_additions.rb b/lib/neoid/model_additions.rb index 7170354..14b3a4a 100644 --- a/lib/neoid/model_additions.rb +++ b/lib/neoid/model_additions.rb @@ -2,18 +2,26 @@ module Neoid module ModelAdditions module ClassMethods attr_reader :neoid_config - attr_reader :neoid_options def neoid_config @neoid_config ||= Neoid::ModelConfig.new(self) end def neoidable(options = {}) + # defaults + neoid_config.auto_index = true + neoid_config.enable_model_index = true # but the Neoid.enable_per_model_indexes is false by default. all models will be true only if the primary option is turned on. + yield(neoid_config) if block_given? - @neoid_options = options + + options.each do |key, value| + raise "Neoid #{self.name} model options: No such option #{key}" unless neoid_config.respond_to?("#{key}=") + neoid_config.send("#{key}=", value) + end end - - def neo_index_name + + def neo_model_index_name + raise "Per Model index is not enabled. Nodes/Relationships are auto indexed with node_auto_index/relationship_auto_index" unless Neoid.config.enable_per_model_indexes || neoid_config.enable_model_index @index_name ||= "#{self.name.tableize}_index" end end @@ -37,9 +45,51 @@ def to_neo end end - def neo_resave + def neo_save_after_model_save + return unless self.class.neoid_config.auto_index + neo_save + true + end + + def neo_save + @_neo_destroyed = false + @_neo_representation = _neo_save + end + + alias neo_create neo_save + alias neo_update neo_save + + def neo_destroy + return if @_neo_destroyed + @_neo_destroyed = true + + neo_representation = neo_find_by_id + return unless neo_representation + + begin + neo_representation.del + rescue Neography::NodeNotFoundException => e + Neoid::logger.info "Neoid#neo_destroy entity not found #{self.class.name} #{self.id}" + end + + # Not working yet because Neography can't delete a node and all of its realtionships in a batch, and deleting a node with relationships results an error + # if Neoid::Batch.current_batch + # Neoid::Batch.current_batch << [self.class.delete_command, neo_representation.neo_id] + # else + # begin + # neo_representation.del + # rescue Neography::NodeNotFoundException => e + # Neoid::logger.info "Neoid#neo_destroy entity not found #{self.class.name} #{self.id}" + # end + # end + _reset_neo_representation - neo_update + + true + end + + def neo_unique_id + "#{self.class.name}:#{self.id}" end protected @@ -52,14 +102,7 @@ def neo_properties_to_hash(*attribute_list) private def _neo_representation - @_neo_representation ||= begin - results = neo_find_by_id - if results - neo_load(results.first['self']) - else - neo_create - end - end + @_neo_representation ||= neo_find_by_id || neo_save end def _reset_neo_representation @@ -70,7 +113,9 @@ def _reset_neo_representation def self.included(receiver) receiver.extend ClassMethods receiver.send :include, InstanceMethods - Neoid.models << receiver + + receiver.after_save :neo_save_after_model_save + receiver.after_destroy :neo_destroy end end end diff --git a/lib/neoid/model_config.rb b/lib/neoid/model_config.rb index 3204d17..a2e9820 100644 --- a/lib/neoid/model_config.rb +++ b/lib/neoid/model_config.rb @@ -3,6 +3,8 @@ class ModelConfig attr_reader :properties attr_reader :search_options attr_reader :relationship_options + attr_accessor :enable_model_index + attr_accessor :auto_index def initialize(klass) @klass = klass diff --git a/lib/neoid/node.rb b/lib/neoid/node.rb index fb54849..eac3346 100644 --- a/lib/neoid/node.rb +++ b/lib/neoid/node.rb @@ -1,15 +1,46 @@ module Neoid module Node + def self.from_hash(hash) + node = Neography::Node.new(hash) + node.neo_server = Neoid.db + node + end + module ClassMethods + attr_accessor :neo_subref_node + def neo_subref_rel_type @_neo_subref_rel_type ||= "#{self.name.tableize}_subref" end + def neo_subref_node_rel_type @_neo_subref_node_rel_type ||= self.name.tableize end - + + def delete_command + :delete_node + end + + def neo_model_index + return nil unless Neoid.config.enable_per_model_indexes + + Neoid::logger.info "Node#neo_model_index #{neo_subref_rel_type}" + + gremlin_query = <<-GREMLIN + g.createManualIndex(neo_model_index_name, Vertex.class); + GREMLIN + + # Neoid.logger.info "subref query:\n#{gremlin_query}" + + script_vars = { neo_model_index_name: neo_model_index_name } + + Neoid.execute_script_or_add_to_batch gremlin_query, script_vars + end + def neo_subref_node - @_neo_subref_node ||= begin + return nil unless Neoid.config.enable_subrefs + + @neo_subref_node ||= begin Neoid::logger.info "Node#neo_subref_node #{neo_subref_rel_type}" gremlin_query = <<-GREMLIN @@ -22,19 +53,26 @@ def neo_subref_node g.addEdge(g.v(0), subref, neo_subref_rel_type); } - g.createManualIndex(neo_index_name, Vertex.class); - subref GREMLIN - Neoid.logger.info "subref query:\n#{gremlin_query}" + # Neoid.logger.info "subref query:\n#{gremlin_query}" - node = Neography::Node.load(Neoid.db.execute_script(gremlin_query, neo_subref_rel_type: neo_subref_rel_type, name: self.name, neo_index_name: self.neo_index_name)) + script_vars = { + neo_subref_rel_type: neo_subref_rel_type, + name: self.name + } - node + Neoid.execute_script_or_add_to_batch gremlin_query, script_vars do |value| + Neoid::Node.from_hash(value) + end end end + def reset_neo_subref_node + @neo_subref_node = nil + end + def neo_search(term, options = {}) Neoid.search(self, term, options) end @@ -42,48 +80,72 @@ def neo_search(term, options = {}) module InstanceMethods def neo_find_by_id - Neoid::logger.info "Node#neo_find_by_id #{self.class.neo_index_name} #{self.id}" - Neoid.db.get_node_index(self.class.neo_index_name, :ar_id, self.id) - rescue Neography::NotFoundException - nil + # Neoid::logger.info "Node#neo_find_by_id #{self.class.neo_index_name} #{self.id}" + node = Neoid.db.get_node_auto_index(Neoid::UNIQUE_ID_KEY, self.neo_unique_id) + node.present? ? Neoid::Node.from_hash(node[0]) : nil end - def neo_create + def _neo_save return unless Neoid.enabled? - - data = self.to_neo.merge(ar_type: self.class.name, ar_id: self.id) + + data = self.to_neo.merge(ar_type: self.class.name, ar_id: self.id, Neoid::UNIQUE_ID_KEY => self.neo_unique_id) data.reject! { |k, v| v.nil? } - - node = Neography::Node.create(data) - - retires = 2 - begin - Neography::Relationship.create( - self.class.neo_subref_node_rel_type, - self.class.neo_subref_node, - node + + gremlin_query = <<-GREMLIN + idx = g.idx('node_auto_index'); + q = null; + if (idx) q = idx.get(unique_id_key, unique_id); + + node = null; + if (q && q.hasNext()) { + node = q.next(); + node_data.each { + if (node.getProperty(it.key) != it.value) { + node.setProperty(it.key, it.value); + } + } + } else { + node = g.addVertex(node_data); + if (enable_subrefs) g.addEdge(g.v(subref_id), node, neo_subref_rel_type); + + if (enable_model_index) g.idx(neo_model_index_name).put('ar_id', node.ar_id, node); + } + + node + GREMLIN + + script_vars = { + unique_id_key: Neoid::UNIQUE_ID_KEY, + node_data: data, + unique_id: self.neo_unique_id, + enable_subrefs: Neoid.config.enable_subrefs, + enable_model_index: Neoid.config.enable_per_model_indexes && self.class.neoid_config.enable_model_index + } + + if Neoid.config.enable_subrefs + script_vars.update( + subref_id: self.class.neo_subref_node.neo_id, + neo_subref_rel_type: self.class.neo_subref_rel_type ) - rescue - # something must've happened to the cached subref node, reset and retry - @_neo_subref_node = nil - retires -= 1 - retry if retires > 0 end - Neoid.db.add_node_to_index(self.class.neo_index_name, :ar_id, self.id, node) + if Neoid.config.enable_per_model_indexes && self.class.neoid_config.enable_model_index + script_vars.update( + neo_model_index_name: self.class.neo_model_index_name + ) + end - Neoid::logger.info "Node#neo_create #{self.class.name} #{self.id}, index = #{self.class.neo_index_name}" - - neo_search_index + Neoid::logger.info "Node#neo_save #{self.class.name} #{self.id}" + + node = Neoid.execute_script_or_add_to_batch(gremlin_query, script_vars) do |value| + @_neo_representation = Neoid::Node.from_hash(value) + end.then do |result| + neo_search_index + end node end - - def neo_update - Neoid.db.set_node_properties(neo_node, self.to_neo) - neo_search_index - end - + def neo_search_index return if self.class.neoid_config.search_options.blank? || ( self.class.neoid_config.search_options.index_fields.blank? && @@ -113,29 +175,20 @@ def neo_helper_get_field_value(field, options = {}) end end - def neo_load(node) - Neography::Node.load(node) + def neo_load(hash) + Neoid::Node.from_hash(hash) end def neo_node _neo_representation end - - def neo_destroy - return unless neo_node - Neoid.db.remove_node_from_index(DEFAULT_FULLTEXT_SEARCH_INDEX_NAME, neo_node) - - Neoid.db.remove_node_from_index(self.class.neo_index_name, neo_node) - neo_node.del - _reset_neo_representation - end def neo_after_relationship_remove(relationship) relationship.neo_destroy end def neo_before_relationship_through_remove(record) - rel_model, foreign_key_of_owner, foreign_key_of_record = Neoid.config[:relationship_meta_data][self.class.name.to_s][record.class.name.to_s] + rel_model, foreign_key_of_owner, foreign_key_of_record = Neoid::Relationship.meta_data[self.class.name.to_s][record.class.name.to_s] rel_model = rel_model.to_s.constantize @__neo_temp_rels ||= {} @__neo_temp_rels[record] = rel_model.where(foreign_key_of_owner => self.id, foreign_key_of_record => record.id).first @@ -152,10 +205,6 @@ def self.included(receiver) receiver.extend ClassMethods receiver.send :include, InstanceMethods Neoid.node_models << receiver - - receiver.after_create :neo_create - receiver.after_update :neo_update - receiver.after_destroy :neo_destroy end end end diff --git a/lib/neoid/relationship.rb b/lib/neoid/relationship.rb index 40899ea..e91f894 100644 --- a/lib/neoid/relationship.rb +++ b/lib/neoid/relationship.rb @@ -1,59 +1,100 @@ module Neoid module Relationship + # this is a proxy that delays loading of start_node and end_node from Neo4j until accessed. + # the original Neography Relatioship loaded them on initialization + class RelationshipLazyProxy < ::Neography::Relationship + def start_node + @start_node_from_db ||= @start_node = Neography::Node.load(@start_node, Neoid.db) + end + + def end_node + @end_node_from_db ||= @end_node = Neography::Node.load(@end_node, Neoid.db) + end + end + + def self.from_hash(hash) + relationship = RelationshipLazyProxy.new(hash) + + relationship + end + + + module ClassMethods + def delete_command + :delete_relationship + end + end + module InstanceMethods def neo_find_by_id - Neoid.db.get_relationship_index(self.class.neo_index_name, :ar_id, self.id) - rescue Neography::NotFoundException - nil + results = Neoid.db.get_relationship_auto_index(Neoid::UNIQUE_ID_KEY, self.neo_unique_id) + relationship = results.present? ? Neoid::Relationship.from_hash(results[0]) : nil + relationship end - def neo_create + def _neo_save return unless Neoid.enabled? - @_neo_destroyed = false options = self.class.neoid_config.relationship_options - start_node = self.send(options[:start_node]) - end_node = self.send(options[:end_node]) + start_item = self.send(options[:start_node]) + end_item = self.send(options[:end_node]) - return unless start_node && end_node + return unless start_item && end_item + + # initialize nodes + start_item.neo_node + end_item.neo_node - data = self.to_neo.merge(ar_type: self.class.name, ar_id: self.id) + data = self.to_neo.merge(ar_type: self.class.name, ar_id: self.id, Neoid::UNIQUE_ID_KEY => self.neo_unique_id) data.reject! { |k, v| v.nil? } - - relationship = Neography::Relationship.create( - options[:type].is_a?(Proc) ? options[:type].call(self) : options[:type], - start_node.neo_node, - end_node.neo_node, - data - ) - - Neoid.db.add_relationship_to_index(self.class.neo_index_name, :ar_id, self.id, relationship) - Neoid::logger.info "Relationship#neo_create #{self.class.name} #{self.id}, index = #{self.class.neo_index_name}" + rel_type = options[:type].is_a?(Proc) ? options[:type].call(self) : options[:type] + + gremlin_query = <<-GREMLIN + idx = g.idx('relationship_auto_index'); + q = null; + if (idx) q = idx.get(unique_id_key, unique_id); + + relationship = null; + if (q && q.hasNext()) { + relationship = q.next(); + relationship_data.each { + if (relationship.getProperty(it.key) != it.value) { + relationship.setProperty(it.key, it.value); + } + } + } else { + node_index = g.idx('node_auto_index'); + start_node = node_index.get(unique_id_key, start_node_unique_id).next(); + end_node = node_index.get(unique_id_key, end_node_unique_id).next(); + + relationship = g.addEdge(start_node, end_node, rel_type, relationship_data); + } + + relationship + GREMLIN + + script_vars = { + unique_id_key: Neoid::UNIQUE_ID_KEY, + relationship_data: data, + unique_id: self.neo_unique_id, + start_node_unique_id: start_item.neo_unique_id, + end_node_unique_id: end_item.neo_unique_id, + rel_type: rel_type + } + + Neoid::logger.info "Relationship#neo_save #{self.class.name} #{self.id}" - relationship - end - - def neo_load(relationship) - Neography::Relationship.load(relationship) - end - - def neo_destroy - return if @_neo_destroyed - @_neo_destroyed = true - return unless neo_relationship - Neoid.db.remove_relationship_from_index(self.class.neo_index_name, neo_relationship) - neo_relationship.del - _reset_neo_representation - - Neoid::logger.info "Relationship#neo_destroy #{self.class.name} #{self.id}, index = #{self.class.neo_index_name}" + relationship = Neoid.execute_script_or_add_to_batch gremlin_query, script_vars do |value| + Neoid::Relationship.from_hash(value) + end - true + relationship end - def neo_update - Neoid.db.set_relationship_properties(neo_relationship, self.to_neo) if neo_relationship + def neo_load(hash) + Neoid::Relationship.from_hash(hash) end def neo_relationship @@ -64,16 +105,15 @@ def neo_relationship def self.included(receiver) receiver.send :include, Neoid::ModelAdditions receiver.send :include, InstanceMethods + receiver.extend ClassMethods - receiver.after_create :neo_create - receiver.after_destroy :neo_destroy - receiver.after_update :neo_update - - if Neoid.env_loaded - initialize_relationship receiver - else - Neoid.relationship_models << receiver - end + initialize_relationship receiver if Neoid.env_loaded + + Neoid.relationship_models << receiver + end + + def self.meta_data + @meta_data ||= {} end def self.initialize_relationship(rel_model) @@ -90,7 +130,6 @@ def self.initialize_relationship(rel_model) # e.g. User has_many :likes, after_remove: ... full_callback_name = "after_remove_for_#{this_has_many.name}" belongs_to.klass.send(full_callback_name) << :neo_after_relationship_remove if belongs_to.klass.method_defined?(full_callback_name) - # belongs_to.klass.send(:has_many, this_has_many.name, this_has_many.options.merge(after_remove: :neo_after_relationship_remove)) # has_many (with through) on the side of the relationship that removes a relationship. e.g. User has_many :movies, through :likes many_to_many = all_has_many.find { |o| o.options[:through] == this_has_many.name } @@ -104,24 +143,20 @@ def self.initialize_relationship(rel_model) # movie_id foreign_key_of_record = many_to_many.source_reflection.foreign_key - (Neoid.config[:relationship_meta_data] ||= {}).tap do |data| + (Neoid::Relationship.meta_data ||= {}).tap do |data| (data[belongs_to.klass.name.to_s] ||= {}).tap do |model_data| model_data[many_to_many.klass.name.to_s] = [rel_model.name.to_s, foreign_key_of_owner, foreign_key_of_record] end end - # puts Neoid.config[:relationship_meta_data].inspect - # e.g. User has_many :movies, through: :likes, before_remove: ... full_callback_name = "before_remove_for_#{many_to_many.name}" belongs_to.klass.send(full_callback_name) << :neo_before_relationship_through_remove if belongs_to.klass.method_defined?(full_callback_name) - # belongs_to.klass.send(:has_many, many_to_many.name, many_to_many.options.merge(before_remove: :neo_after_relationship_remove)) # e.g. User has_many :movies, through: :likes, after_remove: ... full_callback_name = "after_remove_for_#{many_to_many.name}" belongs_to.klass.send(full_callback_name) << :neo_after_relationship_through_remove if belongs_to.klass.method_defined?(full_callback_name) - # belongs_to.klass.send(:has_many, many_to_many.name, many_to_many.options.merge(after_remove: :neo_after_relationship_remove)) end end end diff --git a/lib/neoid/version.rb b/lib/neoid/version.rb index ffb3811..d5950d9 100644 --- a/lib/neoid/version.rb +++ b/lib/neoid/version.rb @@ -1,3 +1,3 @@ module Neoid - VERSION = "0.0.51" + VERSION = "0.1" end diff --git a/spec/neoid/batch_spec.rb b/spec/neoid/batch_spec.rb new file mode 100644 index 0000000..37cfa2b --- /dev/null +++ b/spec/neoid/batch_spec.rb @@ -0,0 +1,170 @@ +require 'spec_helper' + +describe Neoid::ModelAdditions do + context "promises" do + it "should run scripts in a batch and return results" do + Neoid.batch do |batch| + batch << [:execute_script, "1"] + batch << [:execute_script, "2"] + end.then do |results| + results.should == [1, 2] + end + end + + it "should run scripts in a batch with batch_size and flush batch when it's full" do + Neoid.batch(batch_size: 3) do |batch| + (0...9).each do |i| + batch.count.should == i % 3 + batch << [:execute_script, i.to_s] + if i % 3 == 0 + batch.results.count.should == i + end + end + end + end + + it "should run scripts in a batch with batch_size and return all results" do + Neoid.batch(batch_size: 2) do |batch| + (1..6).each do |i| + batch << [:execute_script, i.to_s] + end + end.then do |results| + results.should == [1, 2, 3, 4, 5, 6] + end + end + + it "should return results then process them" do + node_1 = Neoid.db.create_node + node_2 = Neoid.db.create_node + rel = Neoid.db.create_relationship(:related, node_1, node_2) + + Neoid.batch do |batch| + batch << [:execute_script, "g.v(neo_id)", neo_id: node_1['self'].split('/').last.to_i] + batch << [:execute_script, "g.v(neo_id)", neo_id: node_2['self'].split('/').last] + batch << [:execute_script, "g.e(neo_id)", neo_id: rel['self'].split('/').last] + end.then do |results| + results[0].should be_a(Neography::Node) + results[1].should be_a(Neography::Node) + results[2].should be_a(Neography::Relationship) + end + end + + it "should remember what to do after each script has executed, and perform it when batch is flushed" do + then_results = [] + + Neoid.batch do |batch| + (batch << [:execute_script, "1"]).then { |res| then_results << res } + (batch << [:execute_script, "2"]).then { |res| then_results << res } + batch << [:execute_script, "3"] + (batch << [:execute_script, "4"]).then { |res| then_results << res } + end.then do |results| + results.should == [1, 2, 3, 4] + then_results.should == [1, 2, 4] + end + end + end + + context "nodes" do + it "should not execute until batch is done" do + u1 = u2 = nil + + res = Neoid.batch do + u1 = User.create!(name: "U1") + u2 = User.create!(name: "U2") + + u1.neo_find_by_id.should be_nil + u2.neo_find_by_id.should be_nil + end + + res.length.should == 2 + + u1.neo_find_by_id.should_not be_nil + u2.neo_find_by_id.should_not be_nil + end + + it "should update nodes in batch" do + u1 = User.create!(name: "U1") + u2 = User.create!(name: "U2") + + res = Neoid.batch do + u1.name = "U1 update" + u2.name = "U2 update" + + u1.save! + u2.save! + + u1.neo_find_by_id.name.should == "U1" + u2.neo_find_by_id.name.should == "U2" + end + + res.length.should == 2 + + u1.neo_find_by_id.name.should == "U1 update" + u2.neo_find_by_id.name.should == "U2 update" + end + + + # Not working yet because Neography can't delete a node and all of its realtionships in a batch, and deleting a node with relationships results an error + # it "should delete nodes in batch" do + # u1 = User.create!(name: "U1") + # u2 = User.create!(name: "U2") + + # res = Neoid.batch do + # u1_node_id = u1.neo_find_by_id.neo_id + # u2_node_id = u2.neo_find_by_id.neo_id + + # u1.destroy + # u2.destroy + + # Neoid.db.get_node(u1_node_id).should_not be_nil + # Neoid.db.get_node(u2_node_id).should_not be_nil + # end + + # res.length.should == 2 + + # Neoid.db.get_node(u1_node_id).should be_nil + # Neoid.db.get_node(u2_node_id).should be_nil + # end + end + + context "relationships" do + let(:user) { User.create(name: "Elad Ossadon", slug: "elado") } + let(:movie) { Movie.create(name: "Memento", slug: "memento-1999", year: 1999) } + + it "should not execute until batch is done" do + # ensure user and movie nodes are inserted + user + movie + + res = Neoid.batch do |batch| + user.like! movie + + user.likes.last.neo_find_by_id.should be_nil + end + + res.length.should == 1 + + user.likes.last.neo_find_by_id.should_not be_nil + end + + it "should not execute until batch is done" do + # ensure user and movie nodes are inserted + user + movie + + # then destroy the nodes, allow the relationship do that in the batch + user.neo_destroy + movie.neo_destroy + + res = Neoid.batch do |batch| + user.like! movie + + user.likes.last.neo_find_by_id.should be_nil + end + + res.length.should == 3 + + user.likes.last.neo_find_by_id.should_not be_nil + end + end +end diff --git a/spec/neoid/config_spec.rb b/spec/neoid/config_spec.rb new file mode 100644 index 0000000..01adc26 --- /dev/null +++ b/spec/neoid/config_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Neoid::Config do + context "config" do + it "should store and read config" do + Neoid.configure do |config| + config.enable_subrefs = false + end + + Neoid.config.enable_subrefs.should == false + end + end +end diff --git a/spec/neoid/model_additions_spec.rb b/spec/neoid/model_additions_spec.rb index 479c5fb..9f35d36 100644 --- a/spec/neoid/model_additions_spec.rb +++ b/spec/neoid/model_additions_spec.rb @@ -1,16 +1,15 @@ require 'spec_helper' -require 'fileutils' describe Neoid::ModelAdditions do context "nodes" do context "create graph nodes" do - it "should call neo_create on a neo_node for user" do - User.any_instance.should_receive(:neo_create) - - User.create!(name: "Elad Ossadon") + it "should call neo_save after model creation" do + user = User.new(name: "Elad Ossadon") + user.should_receive(:neo_save) + user.save! end - it "should create a neo_node for user" do + it "should create a node for user" do user = User.create!(name: "Elad Ossadon", slug: "elado") user.neo_node.should_not be_nil @@ -22,7 +21,7 @@ it "should create a neo_node for movie" do movie = Movie.create!(name: "Memento", slug: "memento-1999", year: 1999) - + movie.neo_node.should_not be_nil movie.neo_node.ar_id.should == movie.id @@ -31,26 +30,110 @@ end end + context "update graph nodes" do + it "should call neo_save after model update" do + user = User.create!(name: "Elad Ossadon") + user.should_receive(:neo_save) + user.name = "John Doe" + user.save! + end + + it "should update a node after model update" do + user = User.create!(name: "Elad Ossadon") + user.neo_node.name.should == "Elad Ossadon" + + user.name = "John Doe" + user.save! + + user.neo_node.name.should == "John Doe" + end + end + context "find by id" do it "should find a neo_node for user" do user = User.create!(name: "Elad Ossadon", slug: "elado") - + user.neo_node.should_not be_nil user.neo_find_by_id.should_not be_nil end end + + context "no auto_index" do + it "should not index a node if option :auto_index is set to false" do + model = NoAutoIndexNode.new(name: "Hello") + model.should_not_receive(:neo_save) + model.save! + end + end + + context "subrefs" do + it "should create a relationship with a subref node" do + old, Neoid.config.enable_subrefs = Neoid.config.enable_subrefs, true + + Neoid.send(:initialize_subrefs) + + begin + user = User.create!(name: "Elad") + user.neo_node.rel(:incoming, :users_subref).should_not be_nil + ensure + Neoid.config.enable_subrefs = old + end + end + + it "should not create a relationship with a subref node if disabled" do + old, Neoid.config.enable_subrefs = Neoid.config.enable_subrefs, false + + begin + user = User.create!(name: "Elad") + user.neo_node.rel(:incoming, :users_subref).should be_nil + ensure + Neoid.config.enable_subrefs = old + end + end + end + + context "per_model_indexes" do + it "should create a relationship with a subref node" do + old, Neoid.config.enable_per_model_indexes = Neoid.config.enable_per_model_indexes, true + + Neoid.send(:initialize_per_model_indexes) + + begin + user = User.create!(name: "Elad") + Neoid.db.get_node_index(User.neo_model_index_name, 'ar_id', user.id).should_not be_nil + ensure + Neoid.config.enable_per_model_indexes = old + end + end + + it "should not create a relationship with a subref node if disabled" do + old, Neoid.config.enable_per_model_indexes = Neoid.config.enable_per_model_indexes, false + + begin + user = User.create!(name: "Elad") + expect { Neoid.db.get_node_index(User.neo_model_index_name, 'ar_id', user.id) }.to raise_error(Neography::NotFoundException) + ensure + Neoid.config.enable_per_model_indexes = old + end + end + end end context "relationships" do - let(:user) { User.create(name: "Elad Ossadon", slug: "elado") } - let(:movie) { Movie.create(name: "Memento", slug: "memento-1999", year: 1999) } + let(:user) { User.create!(name: "Elad Ossadon", slug: "elado") } + let(:movie) { Movie.create!(name: "Memento", slug: "memento-1999", year: 1999) } - it "should create a relationship on neo4j" do + it "should call neo_save after relationship model creation" do + Like.any_instance.should_receive(:neo_save) user.like! movie - like = user.likes.first - + end + + it "should create a neo_relationship for like" do + like = user.like! movie + like = user.likes.last + like.neo_find_by_id.should_not be_nil - + like.neo_relationship.should_not be_nil like.neo_relationship.start_node.should == user.neo_node @@ -63,7 +146,7 @@ like = user.likes.last relationship_neo_id = like.neo_relationship.neo_id - + Neography::Relationship.load(relationship_neo_id).should_not be_nil user.unlike! movie @@ -98,12 +181,23 @@ user.movie_ids = movies[0...2].collect(&:id) }.to change{ user.neo_node.outgoing(:likes).length }.to(2) end + + it "should update a relationship after relationship model update" do + like = user.like! movie + + like.neo_relationship.rate.should be_nil + + like.rate = 10 + like.save! + + like.neo_relationship.rate.should == 10 + end end context "polymorphic relationship" do let(:user) { User.create(name: "Elad Ossadon", slug: "elado") } - it "description" do + it "should create relationships with polymorphic items" do followed = [ User.create(name: "Some One", slug: "someone"), Movie.create(name: "The Prestige"), @@ -112,7 +206,7 @@ expect { followed.each do |item| - user.user_follows.create(item: item) + user.user_follows.create!(item: item) end }.to change{ user.neo_node.outgoing(:follows).length }.to(followed.length) diff --git a/spec/neoid/search_spec.rb b/spec/neoid/search_spec.rb index ba7158d..bb10cb9 100644 --- a/spec/neoid/search_spec.rb +++ b/spec/neoid/search_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'fileutils' describe Neoid::ModelAdditions do context "search" do diff --git a/spec/neoid_spec.rb b/spec/neoid_spec.rb index 0a60449..40d49b0 100644 --- a/spec/neoid_spec.rb +++ b/spec/neoid_spec.rb @@ -1,5 +1,11 @@ require 'spec_helper' describe Neoid do - + context "subrefs" do + it "should create all subrefs on initialization" do + Neoid.node_models.each do |klass| + klass.instance_variable_get(:@neo_subref_node).should_not be_nil + end + end + end end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ef1418f..0605e36 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,8 @@ require 'neography' require 'rest-client' +# ENV['NEOID_LOG'] = 'true' + uri = URI.parse(ENV["NEO4J_URL"] ? ENV["NEO4J_URL"] : ENV['TRAVIS'] ? "http://localhost:7474" : "http://localhost:7574") $neo = Neography::Rest.new(uri.to_s) @@ -19,9 +21,15 @@ Neoid.db = $neo +logger, ActiveRecord::Base.logger = ActiveRecord::Base.logger, Logger.new('/dev/null') ActiveRecord::Base.configurations = YAML::load(IO.read(File.join(File.dirname(__FILE__), 'support/database.yml'))) ActiveRecord::Base.establish_connection('sqlite3') +require 'support/schema' +require 'support/models' + +ActiveRecord::Base.logger = logger + RSpec.configure do |config| config.mock_with :rspec @@ -29,16 +37,10 @@ end config.before(:each) do - Neoid.reset_cached_variables - end - - config.before(:each) do + Neoid.node_models.each(&:destroy_all) Neoid.clean_db(:yes_i_am_sure) - Neoid.models.each(&:destroy_all) + Neoid.reset_cached_variables end end -require 'support/schema' -require 'support/models' - -Neoid.initialize_all \ No newline at end of file +Neoid.initialize_all diff --git a/spec/support/models.rb b/spec/support/models.rb index ef0b169..800c84f 100644 --- a/spec/support/models.rb +++ b/spec/support/models.rb @@ -12,6 +12,7 @@ def likes?(movie) def like!(movie) movies << movie unless likes?(movie) + likes.where(movie_id: movie.id).first end def unlike!(movie) @@ -52,7 +53,7 @@ class Movie < ActiveRecord::Base class UserFollow < ActiveRecord::Base include ActiveModel::Validations::Callbacks - belongs_to :user, dependent: :destroy + belongs_to :user belongs_to :item, polymorphic: true include Neoid::Relationship @@ -65,8 +66,8 @@ class UserFollow < ActiveRecord::Base class Like < ActiveRecord::Base include ActiveModel::Validations::Callbacks - belongs_to :user, dependent: :destroy - belongs_to :movie, dependent: :destroy + belongs_to :user + belongs_to :movie include Neoid::Relationship @@ -95,3 +96,11 @@ class Article < ActiveRecord::Base end end end + +class NoAutoIndexNode < ActiveRecord::Base + include ActiveModel::Validations::Callbacks + include Neoid::Node + neoidable auto_index: false do |c| + c.field :name + end +end diff --git a/spec/support/schema.rb b/spec/support/schema.rb index 4e3d11d..97154a6 100644 --- a/spec/support/schema.rb +++ b/spec/support/schema.rb @@ -37,4 +37,8 @@ t.timestamps end + + create_table :no_auto_index_nodes do |t| + t.string :name + end end