├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── .rvmrc ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENCE.txt ├── README.md ├── Rakefile ├── lib ├── neo4apis-activerecord.rb └── neo4apis │ ├── activerecord.rb │ ├── cli │ └── activerecord.rb │ ├── model_resolver.rb │ └── table_resolver.rb ├── neo4apis-activerecord.gemspec ├── spec ├── lib │ ├── model_resolver_spec.rb │ ├── model_spec.rb │ └── table_resolver_spec.rb └── spec_helper.rb └── test.db /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | 4 | # Should remove when this issue has been resolved: 5 | # https://github.com/bbatsov/rubocop/issues/1544 6 | Lint/ShadowingOuterLocalVariable: 7 | Enabled: false 8 | 9 | 10 | 11 | #--------------------------- 12 | # Style configuration 13 | #--------------------------- 14 | 15 | # Offense count: 169 16 | # Cop supports --auto-correct. 17 | # Configuration parameters: EnforcedStyle, SupportedStyles. 18 | Style/HashSyntax: 19 | Enabled: true 20 | EnforcedStyle: ruby19 21 | 22 | # Cop supports --auto-correct. 23 | Style/SpaceInsideHashLiteralBraces: 24 | Enabled: true 25 | EnforcedStyle: no_space 26 | 27 | 28 | # I think this one is broken... 29 | Style/FileName: 30 | Enabled: false 31 | 32 | 33 | #--------------------------- 34 | # Don't intend to fix these: 35 | #--------------------------- 36 | 37 | # Cop supports --auto-correct. 38 | # Reason: Double spaces can be useful for grouping code 39 | Style/EmptyLines: 40 | Enabled: false 41 | 42 | # Cop supports --auto-correct. 43 | # Reason: I have very big opinions on this one. See: 44 | # https://github.com/bbatsov/ruby-style-guide/issues/329 45 | # https://github.com/bbatsov/ruby-style-guide/pull/325 46 | Style/NegatedIf: 47 | Enabled: false 48 | 49 | # Cop supports --auto-correct. 50 | # Reason: I'm fine either way on this, but could maybe be convinced that this should be enforced 51 | Style/Not: 52 | Enabled: false 53 | 54 | # Cop supports --auto-correct. 55 | # Reason: I'm fine with this 56 | Style/PerlBackrefs: 57 | Enabled: false 58 | 59 | # Configuration parameters: Methods. 60 | # Reason: We should be able to specify full variable names, even if it's only one line 61 | Style/SingleLineBlockParams: 62 | Enabled: false 63 | 64 | # Offense count: 1 65 | # Reason: Switched `extend self` to `module_function` in id_property.rb but that caused errors 66 | Style/ModuleFunction: 67 | Enabled: false 68 | 69 | # Configuration parameters: AllowSafeAssignment. 70 | # Reason: I'm a proud user of assignment in conditionals. 71 | Lint/AssignmentInCondition: 72 | Enabled: false 73 | 74 | # Reason: I'm proud to be part of the double negative Ruby tradition 75 | Style/DoubleNegation: 76 | Enabled: false 77 | 78 | Style/MultilineBlockChain: 79 | Enabled: false 80 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by `rubocop --auto-gen-config` 2 | # on 2015-01-09 15:20:17 +0200 using RuboCop version 0.28.0. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the offenses are removed from the code base. 5 | # Note that changes in the inspected code, or installation of new 6 | # versions of RuboCop, may require this file to be generated again. 7 | 8 | # Offense count: 1 9 | # Cop supports --auto-correct. 10 | Lint/UnusedMethodArgument: 11 | Enabled: false 12 | 13 | # Offense count: 4 14 | Metrics/AbcSize: 15 | Max: 28 16 | 17 | # Offense count: 1 18 | # Configuration parameters: CountComments. 19 | Metrics/ClassLength: 20 | Max: 113 21 | 22 | # Offense count: 1 23 | Metrics/CyclomaticComplexity: 24 | Max: 8 25 | 26 | # Offense count: 42 27 | # Configuration parameters: AllowURI, URISchemes. 28 | Metrics/LineLength: 29 | Max: 185 30 | 31 | # Offense count: 5 32 | # Configuration parameters: CountComments. 33 | Metrics/MethodLength: 34 | Max: 20 35 | 36 | # Offense count: 5 37 | Style/Documentation: 38 | Enabled: false 39 | 40 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm use 2.1.5@neo4apis-activerecord 2 | 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem 'pry' 7 | gem 'neo4apis', path: '../neo4apis' 8 | gem 'rubocop' 9 | end 10 | 11 | group :test do 12 | gem 'rspec' 13 | gem 'sqlite3' 14 | 15 | gem 'guard' 16 | gem 'guard-rspec' 17 | gem 'guard-rubocop' 18 | end 19 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | neo4apis-activerecord (0.9.1) 5 | activerecord (~> 4.0) 6 | composite_primary_keys (~> 8.0) 7 | neo4apis (>= 0.8.1) 8 | 9 | PATH 10 | remote: ../neo4apis 11 | specs: 12 | neo4apis (0.8.2) 13 | colorize (~> 0.7.3) 14 | faraday (~> 0.9.0) 15 | neo4j-core (>= 4.0.5) 16 | thor (~> 0.19.1) 17 | 18 | GEM 19 | remote: https://rubygems.org/ 20 | specs: 21 | activemodel (4.2.4) 22 | activesupport (= 4.2.4) 23 | builder (~> 3.1) 24 | activerecord (4.2.4) 25 | activemodel (= 4.2.4) 26 | activesupport (= 4.2.4) 27 | arel (~> 6.0) 28 | activesupport (4.2.4) 29 | i18n (~> 0.7) 30 | json (~> 1.7, >= 1.7.7) 31 | minitest (~> 5.1) 32 | thread_safe (~> 0.3, >= 0.3.4) 33 | tzinfo (~> 1.1) 34 | arel (6.0.3) 35 | ast (2.0.0) 36 | astrolabe (1.3.0) 37 | parser (>= 2.2.0.pre.3, < 3.0) 38 | builder (3.2.2) 39 | celluloid (0.16.0) 40 | timers (~> 4.0.0) 41 | coderay (1.1.0) 42 | colored (1.2) 43 | colorize (0.7.7) 44 | composite_primary_keys (8.1.1) 45 | activerecord (~> 4.2.0) 46 | diff-lcs (1.2.5) 47 | faraday (0.9.1) 48 | multipart-post (>= 1.2, < 3) 49 | faraday_middleware (0.9.2) 50 | faraday (>= 0.7.4, < 0.10) 51 | faraday_middleware-multi_json (0.0.6) 52 | faraday_middleware 53 | multi_json 54 | ffi (1.9.6) 55 | formatador (0.2.5) 56 | guard (2.10.5) 57 | formatador (>= 0.2.4) 58 | listen (~> 2.7) 59 | lumberjack (~> 1.0) 60 | nenv (~> 0.1) 61 | pry (>= 0.9.12) 62 | thor (>= 0.18.1) 63 | guard-compat (1.2.0) 64 | guard-rspec (4.5.0) 65 | guard (~> 2.1) 66 | guard-compat (~> 1.1) 67 | rspec (>= 2.99.0, < 4.0) 68 | guard-rubocop (1.2.0) 69 | guard (~> 2.0) 70 | rubocop (~> 0.20) 71 | hitimes (1.2.2) 72 | httparty (0.13.5) 73 | json (~> 1.8) 74 | multi_xml (>= 0.5.2) 75 | httpclient (2.6.0.1) 76 | i18n (0.7.0) 77 | json (1.8.3) 78 | listen (2.8.4) 79 | celluloid (>= 0.15.2) 80 | rb-fsevent (>= 0.9.3) 81 | rb-inotify (>= 0.9) 82 | lumberjack (1.0.9) 83 | method_source (0.8.2) 84 | minitest (5.8.0) 85 | multi_json (1.11.2) 86 | multi_xml (0.5.5) 87 | multipart-post (2.0.0) 88 | nenv (0.1.1) 89 | neo4j-core (5.1.1) 90 | activesupport 91 | faraday (~> 0.9.0) 92 | faraday_middleware (~> 0.9.1) 93 | faraday_middleware-multi_json 94 | httparty 95 | httpclient 96 | json 97 | multi_json 98 | neo4j-rake_tasks 99 | net-http-persistent 100 | neo4j-rake_tasks (0.0.9) 101 | colored 102 | httparty 103 | os 104 | rake 105 | zip 106 | net-http-persistent (2.9.4) 107 | os (0.9.6) 108 | parser (2.2.0.1) 109 | ast (>= 1.1, < 3.0) 110 | slop (~> 3.4, >= 3.4.5) 111 | powerpack (0.0.9) 112 | pry (0.10.1) 113 | coderay (~> 1.1.0) 114 | method_source (~> 0.8.1) 115 | slop (~> 3.4) 116 | rainbow (2.0.0) 117 | rake (10.4.2) 118 | rb-fsevent (0.9.4) 119 | rb-inotify (0.9.5) 120 | ffi (>= 0.5.0) 121 | rspec (3.1.0) 122 | rspec-core (~> 3.1.0) 123 | rspec-expectations (~> 3.1.0) 124 | rspec-mocks (~> 3.1.0) 125 | rspec-core (3.1.7) 126 | rspec-support (~> 3.1.0) 127 | rspec-expectations (3.1.2) 128 | diff-lcs (>= 1.2.0, < 2.0) 129 | rspec-support (~> 3.1.0) 130 | rspec-mocks (3.1.3) 131 | rspec-support (~> 3.1.0) 132 | rspec-support (3.1.2) 133 | rubocop (0.28.0) 134 | astrolabe (~> 1.3) 135 | parser (>= 2.2.0.pre.7, < 3.0) 136 | powerpack (~> 0.0.6) 137 | rainbow (>= 1.99.1, < 3.0) 138 | ruby-progressbar (~> 1.4) 139 | ruby-progressbar (1.7.1) 140 | slop (3.6.0) 141 | sqlite3 (1.3.10) 142 | thor (0.19.1) 143 | thread_safe (0.3.5) 144 | timers (4.0.1) 145 | hitimes 146 | tzinfo (1.2.2) 147 | thread_safe (~> 0.1) 148 | zip (2.0.2) 149 | 150 | PLATFORMS 151 | ruby 152 | 153 | DEPENDENCIES 154 | guard 155 | guard-rspec 156 | guard-rubocop 157 | neo4apis! 158 | neo4apis-activerecord! 159 | pry 160 | rspec 161 | rubocop 162 | sqlite3 163 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rubocop, cli: '--auto-correct --display-cop-names' do 2 | watch(/.+\.rb$/) 3 | watch(/(?:.+\/)?\.rubocop.*\.yml$/) { |m| File.dirname(m[0]) } 4 | end 5 | 6 | guard :rspec, cmd: 'rspec' do 7 | watch(/^spec\/.+_spec\.rb$/) 8 | watch(/^lib\/(.+)\.rb$/) { |m| "spec/lib/#{m[1]}_spec.rb" } 9 | watch('spec/spec_helper.rb') { 'spec' } 10 | end 11 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neo4apis-activerecord 2 | 3 | **The easiest and quickest way to copy data from PostgreSQL / mySQL / sqlite to Neo4j** 4 | 5 | ## How to run: 6 | 7 | Without existing ActiveRecord application: 8 | 9 | neo4apis activerecord all_tables --identify-model --import-all-associations 10 | 11 | or 12 | 13 | neo4apis activerecord tables posts comments --identify-model --import-all-associations 14 | 15 | With existing ActiveRecord application: 16 | 17 | neo4apis activerecord all_models --import-all-associations 18 | 19 | or 20 | 21 | neo4apis activerecord models Post Comment --import-all-associations 22 | 23 | ## Installation 24 | 25 | Using rubygems: 26 | 27 | gem install neo4apis-activerecord 28 | 29 | ## How it works 30 | 31 | [ActiveRecord](http://guides.rubyonrails.org/active_record_basics.html) is a [ORM](http://en.wikipedia.org/wiki/Object-relational_mapping) for ruby. `neo4apis-activerecord` uses ActiveRecord models which are either found in an existing ruby app or generated from table structures. The models are then introspected to create nodes (from tables) and relationships (from associations) in neo4j. The neo4apis library is used to load data efficiently in batches. 32 | 33 | ## Options 34 | 35 | For a list of all options run: 36 | 37 | neo4apis activerecord --help 38 | 39 | Some options of particular interest: 40 | 41 | ### `--identify-model` 42 | 43 | The `--identify-model` option looks for tables' names/primay keys/foreign keys automatically. Potential options are generated and the database is examined to find out which fits. 44 | 45 | As an example: for a table of posts the following possibilities would checked: 46 | 47 | * Names: Looks for names like `posts`, `post`, `Posts`, or `Post` 48 | * Primary keys: Table schema is examined first. If no primary key is specified it will look for columns like `id`, `post_id`, `PostId`, or `uuid` 49 | * Foreign keys: `author_id` or `AuthorId` will be assumed to go to a table of authors (with a name identified as above) 50 | 51 | ### `--import-belongs-to` 52 | ### `--import-has-many` 53 | ### `--import-has-one` 54 | ### `--import-all-associations` 55 | 56 | Either specify that a certain class of associations be imported from ActiveRecord models or specify all with `--import-all-associations` 57 | 58 | ## Using `neo4apis-activerecord` from ruby 59 | 60 | If you'd like to do custom importing, you can use `neo4apis-activerecord` in the following way: 61 | 62 | Neo4Apis::ActiveRecord.model_importer(SomeModel) 63 | 64 | neo4apis_activerecord = Neo4Apis::ActiveRecord.new(Neo4j::Session.open, import_all_associations: true) 65 | 66 | neo4apis_activerecord.batch do 67 | SomeModel.where(condition: 'value').find_each do |object| 68 | neo4apis_activerecord.import object 69 | end 70 | end 71 | 72 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'bundler/gem_tasks' 3 | -------------------------------------------------------------------------------- /lib/neo4apis-activerecord.rb: -------------------------------------------------------------------------------- 1 | require 'neo4apis' 2 | 3 | require 'neo4apis/activerecord' 4 | require 'neo4apis/cli/activerecord' 5 | -------------------------------------------------------------------------------- /lib/neo4apis/activerecord.rb: -------------------------------------------------------------------------------- 1 | require 'neo4apis' 2 | require 'ostruct' 3 | require 'composite_primary_keys' 4 | 5 | module Neo4Apis 6 | class ActiveRecord < Base 7 | batch_size 1000 8 | 9 | def self.model_importer(model_class) 10 | if model_class.primary_key.is_a?(Array) 11 | relationship_name = model_class.table_name 12 | associations = model_class.reflect_on_all_associations 13 | 14 | importer model_class.name.to_sym do |object| 15 | from_node = object.send(associations[0].name) 16 | from_node = add_model_node from_node.class, from_node 17 | 18 | to_node = object.send(associations[1].name) 19 | 20 | puts 'add_model_relationship...' 21 | add_model_relationship relationship_name, from_node, to_node 22 | end 23 | else 24 | return if model_class.primary_key.nil? 25 | uuid model_class.name.to_sym, model_class.primary_key 26 | 27 | importer model_class.name.to_sym do |object| 28 | node = add_model_node model_class, object 29 | 30 | model_class.reflect_on_all_associations.each do |association_reflection| 31 | case association_reflection.macro 32 | when :belongs_to, :has_one 33 | if options[:"import_#{association_reflection.macro}"] 34 | referenced_object = object.send(association_reflection.name) 35 | add_model_relationship association_reflection.name, node, referenced_object if referenced_object 36 | end 37 | when :has_many, :has_and_belongs_to_many 38 | if options[:"import_#{association_reflection.macro}"] 39 | object.send(association_reflection.name).each do |referenced_object| 40 | add_model_relationship association_reflection.name, node, referenced_object if referenced_object 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | 49 | def add_model_relationship(relationship_name, node, referenced_object) 50 | referenced_class = referenced_object.class 51 | referenced_node = add_model_node referenced_class, referenced_object 52 | 53 | add_relationship relationship_name, node, referenced_node 54 | end 55 | 56 | def add_model_node(model_class, object) 57 | object_data = OpenStruct.new 58 | 59 | object.class.column_names.each do |column_name| 60 | object_data.send("#{column_name}=", attribute_for_coder(object, column_name)) 61 | end 62 | 63 | add_node model_class.name.to_sym, object_data, model_class.column_names 64 | end 65 | 66 | def attribute_for_coder(object, column_name) 67 | column = object.class.columns_hash[column_name] 68 | if column.respond_to?(:cast_type) 69 | column.cast_type.type_cast_from_user(object.attributes[column_name]) 70 | else 71 | value = object.attributes[column_name] 72 | if coder = object.class.serialized_attributes[column_name] 73 | coder.dump(value) 74 | else 75 | value 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/neo4apis/cli/activerecord.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_support/inflector' 3 | require 'thor' 4 | require 'colorize' 5 | require 'neo4apis/model_resolver' 6 | require 'neo4apis/cli/base' 7 | 8 | module Neo4Apis 9 | module CLI 10 | class ActiveRecord < CLI::Base 11 | include ModelResolver 12 | 13 | class_option :debug, type: :boolean, default: false, desc: 'Output debugging information' 14 | 15 | class_option :import_all_associations, type: :boolean, default: false, desc: 'Shortcut for --import-belongs-to --import-has-many --import-has-one' 16 | class_option :import_belongs_to, type: :boolean, default: nil 17 | class_option :import_has_one, type: :boolean, default: nil 18 | class_option :import_has_many, type: :boolean, default: nil 19 | 20 | class_option :identify_model, type: :boolean, default: false, desc: 'Identify table name, primary key, and foreign keys automatically' 21 | 22 | class_option :startup_environment, type: :string, default: './config/environment.rb', desc: 'Script that will be run before import. Needs to establish an ActiveRecord connection' 23 | 24 | class_option :active_record_config_path, type: :string, default: './config/database.yml' 25 | class_option :active_record_environment, type: :string, default: 'development' 26 | 27 | desc 'tables MODELS_OR_TABLE_NAMES', 'Import specified SQL tables' 28 | def tables(*models_or_table_names) 29 | setup 30 | 31 | import_models_or_tables(*models_or_table_names) 32 | end 33 | 34 | desc 'models MODELS_OR_TABLE_NAMES', 'Import specified ActiveRecord models' 35 | def models(*models_or_table_names) 36 | setup 37 | 38 | import_models_or_tables(*models_or_table_names) 39 | end 40 | 41 | desc 'all_tables', 'Import all SQL tables' 42 | def all_tables 43 | setup 44 | 45 | import_models_or_tables(*::ActiveRecord::Base.connection.tables) 46 | end 47 | 48 | desc 'all_models', 'Import SQL tables using defined models' 49 | def all_models 50 | setup 51 | 52 | Rails.application.eager_load! 53 | 54 | import_models_or_tables(*::ActiveRecord::Base.descendants) 55 | end 56 | 57 | private 58 | 59 | def debug_log(*messages) 60 | return unless options[:debug] 61 | 62 | puts(*messages) 63 | end 64 | 65 | def import_models_or_tables(*models_or_table_names) 66 | model_classes = models_or_table_names.map(&method(:get_model)) 67 | 68 | puts 'Importing tables: ' + model_classes.map(&:table_name).join(', ') 69 | 70 | model_classes.each do |model_class| 71 | NEO4APIS_CLIENT_CLASS.model_importer(model_class) 72 | end 73 | 74 | neo4apis_client.batch do 75 | model_classes.each do |model_class| 76 | query = model_class.all 77 | 78 | # Eager load association for faster import 79 | include_list = include_list_for_model(model_class) 80 | query = query.includes(*include_list) if include_list.present? 81 | 82 | query.find_each do |object| 83 | neo4apis_client.import model_class.name.to_sym, object 84 | end 85 | end 86 | end 87 | end 88 | 89 | def include_list_for_model(model_class) 90 | model_class.reflect_on_all_associations.map do |association_reflection| 91 | association_reflection.name.to_sym if import_association?(association_reflection.macro) 92 | end.compact.tap do |include_list| 93 | debug_log 'include_list', include_list.inspect 94 | end 95 | end 96 | 97 | def setup 98 | if File.exist?(options[:startup_environment]) 99 | require options[:startup_environment] 100 | else 101 | ::ActiveRecord::Base.establish_connection(active_record_config) 102 | end 103 | end 104 | 105 | NEO4APIS_CLIENT_CLASS = ::Neo4Apis::ActiveRecord 106 | 107 | def neo4apis_client 108 | @neo4apis_client ||= NEO4APIS_CLIENT_CLASS.new(specified_neo4j_session, 109 | import_belongs_to: import_association?(:belongs_to), 110 | import_has_one: import_association?(:has_one), 111 | import_has_many: import_association?(:has_many)) 112 | end 113 | 114 | def import_association?(type) 115 | options[:"import_#{type}"].nil? ? options[:import_all_associations] : options[:"import_#{type}"] 116 | end 117 | 118 | 119 | def active_record_config 120 | require 'yaml' 121 | YAML.load(File.read(options[:active_record_config_path]))[options[:active_record_environment]] 122 | end 123 | 124 | def tables 125 | ::ActiveRecord::Base.connection.tables 126 | end 127 | end 128 | 129 | class Base < Thor 130 | desc 'activerecord SUBCOMMAND ...ARGS', 'methods of importing data automagically from Twitter' 131 | subcommand 'activerecord', CLI::ActiveRecord 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/neo4apis/model_resolver.rb: -------------------------------------------------------------------------------- 1 | require 'neo4apis/table_resolver' 2 | 3 | module Neo4Apis 4 | class UnfoundTableError < StandardError 5 | end 6 | 7 | module ModelResolver 8 | def self.included(included_class) 9 | included_class.send(:include, TableResolver) 10 | end 11 | 12 | def get_model(model_or_table_name) 13 | get_model_class(model_or_table_name).tap do |model_class| 14 | if options[:identify_model] 15 | apply_identified_table_name!(model_class) 16 | apply_identified_primary_key!(model_class) 17 | apply_identified_model_associations!(model_class) 18 | end 19 | end 20 | end 21 | 22 | def get_model_class(model_or_table_name) 23 | return model_or_table_name if model_or_table_name.is_a?(Class) && model_or_table_name.ancestors.include?(::ActiveRecord::Base) 24 | 25 | model_class = model_or_table_name.gsub(/\s+/, '_') 26 | model_class = model_or_table_name.classify unless model_or_table_name.match(/^[A-Z]/) 27 | model_class.constantize 28 | rescue NameError 29 | Object.const_set(model_class, Class.new(::ActiveRecord::Base)) 30 | end 31 | 32 | def apply_identified_model_associations!(model_class) 33 | model_class.columns.each do |column| 34 | match = column.name.match(/^(.+)_id$/i) || column.name.match(/^(.+)id$/i) 35 | next if not match 36 | 37 | begin 38 | base = match[1].gsub(/ +/, '_').tableize 39 | 40 | if identify_table_name(tables, base.classify) && model_class.name != base.classify 41 | debug_log "Defining: belongs_to #{base.singularize.to_sym.inspect}, foreign_key: #{column.name.inspect}, class_name: #{base.classify.inspect}" 42 | model_class.belongs_to base.singularize.to_sym, foreign_key: column.name, class_name: base.classify 43 | end 44 | rescue UnfoundTableError 45 | nil 46 | end 47 | end 48 | end 49 | 50 | def apply_identified_table_name!(model_class) 51 | identity = identify_table_name(tables, model_class.name) 52 | model_class.table_name = identity if identity 53 | end 54 | 55 | def apply_identified_primary_key!(model_class) 56 | identity = if model_class.columns.map(&:type) == [:integer, :integer] 57 | model_class.columns.map(&:name) 58 | else 59 | ::ActiveRecord::Base.connection.primary_key(model_class.table_name) 60 | end 61 | identity ||= identify_primary_key(model_class.column_names, model_class.name) 62 | 63 | model_class.primary_key = identity if identity 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/neo4apis/table_resolver.rb: -------------------------------------------------------------------------------- 1 | 2 | module Neo4Apis 3 | class UnfoundTableError < StandardError 4 | end 5 | 6 | module TableResolver 7 | class UnfoundPrimaryKeyError < StandardError 8 | end 9 | 10 | def identify_table_name(tables, class_name) 11 | potential_table_comparisons = [class_name.tableize, class_name.tableize.singularize].map(&method(:standardize)) 12 | tables.detect do |table_name| 13 | potential_table_comparisons.include?(standardize(table_name)) 14 | end.tap do |found_name| # rubocop:disable Style/MultilineBlockChain 15 | puts "WARNING: Could not find a table for #{class_name}." if found_name.nil? 16 | end 17 | end 18 | 19 | def identify_primary_key(columns, class_name) 20 | (columns & %w(id uuid)).first 21 | columns.detect do |column| 22 | case standardize(column) 23 | when 'id', 'uuid', /#{standardize(class_name.singularize)}id/, /#{standardize(class_name.pluralize)}id/ 24 | true 25 | end 26 | end.tap do |found_key| # rubocop:disable Style/MultilineBlockChain 27 | fail UnfoundPrimaryKeyError, "Could not find a primary key for #{class_name}." if found_key.nil? 28 | end 29 | end 30 | 31 | private 32 | 33 | def standardize(string) 34 | string.downcase.gsub(/[ _]+/, '') 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /neo4apis-activerecord.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'neo4apis-activerecord' 6 | s.version = '0.9.1' 7 | s.required_ruby_version = '>= 1.9.1' 8 | 9 | s.authors = 'Brian Underwood' 10 | s.email = 'public@brian-underwood.codes' 11 | s.homepage = 'https://github.com/neo4jrb/neo4apis-activerecord/' 12 | s.summary = 'An ruby gem to import SQL data to neo4j using activerecord' 13 | s.license = 'MIT' 14 | s.description = <<-EOF 15 | A ruby gem using neo4apis to make importing SQL data to neo4j easy 16 | EOF 17 | 18 | s.require_path = 'lib' 19 | s.files = Dir.glob('{bin,lib,config}/**/*') + %w(README.md Gemfile neo4apis-activerecord.gemspec) 20 | 21 | s.add_dependency('neo4apis', '>= 0.8.1') 22 | s.add_dependency('activerecord', '~> 4.0') 23 | s.add_dependency('composite_primary_keys', '~> 8.0') 24 | end 25 | -------------------------------------------------------------------------------- /spec/lib/model_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'neo4apis/model_resolver' 3 | 4 | describe Neo4Apis::ModelResolver do 5 | subject do 6 | Object.new.extend(Neo4Apis::ModelResolver) 7 | end 8 | 9 | describe '#get_model_class' do 10 | before do 11 | stub_const('Foo', Class.new(::ActiveRecord::Base)) 12 | 13 | stub_const('Baz', Class.new(Foo)) 14 | end 15 | 16 | it 'returns the class when given an ActiveRecord class' do 17 | expect(subject.get_model_class(Foo)).to eq(Foo) 18 | expect(subject.get_model_class(Baz)).to eq(Baz) 19 | end 20 | 21 | it 'loads the already defined model when appropriate' do 22 | expect(subject.get_model_class('foos')).to eq(Foo) 23 | expect(subject.get_model_class('foo')).to eq(Foo) 24 | 25 | expect(subject.get_model_class('bazs')).to eq(Baz) 26 | expect(subject.get_model_class('baz')).to eq(Baz) 27 | end 28 | 29 | it 'loads a ActiveRecord class when non is defined' do 30 | expect(subject.get_model_class('bars').name).to eq('Bar') 31 | expect(subject.get_model_class('bars').superclass).to eq(::ActiveRecord::Base) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lib/model_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Model import' do 4 | before(:all) do 5 | system('rm test.db') 6 | 7 | ActiveRecord::Base.establish_connection( 8 | adapter: :sqlite3, 9 | database: 'test.db' 10 | ) 11 | 12 | User = Class.new(ActiveRecord::Base) do 13 | self.table_name = 'users' 14 | 15 | def self.name 16 | 'User' 17 | end 18 | 19 | has_many :foo_records 20 | 21 | has_and_belongs_to_many :bars 22 | end 23 | 24 | Bar = Class.new(ActiveRecord::Base) do 25 | self.table_name = 'bars' 26 | 27 | def self.name 28 | 'Bar' 29 | end 30 | end 31 | 32 | 33 | FooRecord = Class.new(ActiveRecord::Base) do 34 | self.table_name = 'foo_records' 35 | 36 | def self.name 37 | 'FooRecord' 38 | end 39 | 40 | belongs_to :user 41 | end 42 | 43 | ActiveRecord::Base.connection 44 | end 45 | 46 | let(:neo4j_url) { 'http://localhost:9500' } 47 | let(:neo4j_connection) { Neo4j::Session.open(:server_db, neo4j_url) } 48 | 49 | let(:neo4japis_activerecord_options) { {} } 50 | let(:neo4japis_activerecord) do 51 | Neo4Apis::ActiveRecord.model_importer(User) 52 | Neo4Apis::ActiveRecord.model_importer(Bar) 53 | Neo4Apis::ActiveRecord.model_importer(FooRecord) 54 | 55 | Neo4Apis::ActiveRecord.new(neo4j_connection, neo4japis_activerecord_options) 56 | end 57 | 58 | 59 | 60 | let(:migration_classes) do 61 | [ 62 | Class.new(ActiveRecord::Migration) do 63 | def change 64 | create_table :users do |table| 65 | table.string :username 66 | 67 | table.timestamps null: true 68 | end 69 | end 70 | end, 71 | Class.new(ActiveRecord::Migration) do 72 | def change 73 | create_table :foo_records, id: false do |table| 74 | table.primary_key :my_uid 75 | 76 | table.references :user, index: true 77 | 78 | table.timestamps null: true 79 | end 80 | end 81 | end, 82 | Class.new(ActiveRecord::Migration) do 83 | def change 84 | create_table :bars do |table| 85 | table.string :name 86 | end 87 | end 88 | end, 89 | Class.new(ActiveRecord::Migration) do 90 | def change 91 | create_table :bars_users, id: false do |table| 92 | table.references :bar 93 | table.references :user 94 | end 95 | 96 | add_index :bars_users, [:bar_id, :user_id] 97 | end 98 | end 99 | ] 100 | end 101 | 102 | before do 103 | migration_classes.each { |c| c.new.migrate(:up) } 104 | clear_neo4j(neo4j_connection, neo4j_url) 105 | end 106 | 107 | after do 108 | migration_classes.reverse.each { |c| c.new.migrate(:down) } 109 | end 110 | 111 | # Helpers 112 | def new_query 113 | neo4j_connection.query 114 | end 115 | 116 | def model_count(model) 117 | new_query.match(u: :User).pluck('count(u)').first 118 | end 119 | 120 | let(:bar) { Bar.create(name: 'bar') } 121 | let(:jimmy) { User.create(username: 'jimmy', bars: [bar]) } 122 | let(:foo_record) { FooRecord.create(my_uid: 'foo_record', user_id: jimmy.id) } 123 | 124 | it 'Can import an ActiveRecord row' do 125 | neo4japis_activerecord.batch do 126 | neo4japis_activerecord.import :User, jimmy 127 | end 128 | 129 | expect(model_count(:User)).to eq(1) 130 | 131 | user_node = neo4j_connection.query.match(u: :User).pluck(:u).first 132 | expect(user_node.props[:username]).to eq('jimmy') 133 | end 134 | 135 | context 'non-standard primary key' do 136 | it 'handles non-standard primary keys' do 137 | neo4japis_activerecord.batch do 138 | neo4japis_activerecord.import :FooRecord, foo_record 139 | end 140 | 141 | expect(neo4j_connection.uniqueness_constraints(:FooRecord)).to eq(property_keys: [[:my_uid]]) 142 | end 143 | end 144 | 145 | describe 'importing assocations' do 146 | it 'does not import assocations when not specified' do 147 | neo4japis_activerecord.batch do 148 | neo4japis_activerecord.import :FooRecord, foo_record 149 | end 150 | 151 | expect(new_query.match(foo: :FooRecord).match('foo--(user)').pluck('user')).to eq([]) 152 | end 153 | 154 | context 'import_belongs_to' do 155 | let(:neo4japis_activerecord_options) { {import_belongs_to: true} } 156 | 157 | it 'does import assocations when specified' do 158 | neo4japis_activerecord.batch do 159 | neo4japis_activerecord.import :FooRecord, foo_record 160 | end 161 | 162 | expect(new_query.match(foo: :FooRecord).match('foo-[:user]->(user)').pluck('user.id')).to eq([jimmy.id]) 163 | end 164 | end 165 | 166 | context 'import_has_many' do 167 | let(:neo4japis_activerecord_options) { {import_has_many: true} } 168 | 169 | it 'does import assocations when specified' do 170 | foo_record # Reference to create 171 | neo4japis_activerecord.batch do 172 | neo4japis_activerecord.import :User, jimmy 173 | end 174 | 175 | expect(new_query.match(user: :User).match('user-[:foo_records]->(foo_record)').pluck('foo_record.my_uid')).to eq([foo_record.my_uid]) 176 | end 177 | end 178 | 179 | context 'import_has_and_belongs_to_many' do 180 | let(:neo4japis_activerecord_options) { {import_has_and_belongs_to_many: true} } 181 | 182 | it 'does import assocations when specified' do 183 | bar # Reference to create 184 | neo4japis_activerecord.batch do 185 | neo4japis_activerecord.import :User, jimmy 186 | end 187 | 188 | expect(new_query.match(user: :User).match('user-[:bars]->(bar)').pluck('bar.id')).to eq([bar.id]) 189 | end 190 | end 191 | 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /spec/lib/table_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'neo4apis/table_resolver' 3 | 4 | describe Neo4Apis::TableResolver do 5 | subject do 6 | Object.new.extend(Neo4Apis::TableResolver) 7 | end 8 | describe '#identify_table_name' do 9 | %w(posts post Posts Post).each do |table_name| 10 | it "identifies #{table_name} table for Post class" do 11 | expect(subject.identify_table_name([table_name], 'Post')).to eq(table_name) 12 | end 13 | end 14 | 15 | ['foo_bars', 'foo_bar', 'FooBars', 'FooBar', 'Foo Bars', 'Foo Bar', 'Foo bar', 'foo Bar', 'fooBar', 'fooBars'].each do |table_name| 16 | it "identifies #{table_name} table for FooBar class" do 17 | expect(subject.identify_table_name([table_name], 'FooBar')).to eq(table_name) 18 | end 19 | end 20 | 21 | 22 | it 'returns nil if nothing is identifiable' do 23 | expect { subject.identify_table_name(['foo'], 'Post') }.to raise_error(Neo4Apis::TableResolver::UnfoundTableError) 24 | end 25 | end 26 | 27 | describe '#identify_primary_key' do 28 | %w(id PostId post_id Post_id Postid postId postID uuid).each do |primary_key| 29 | it "identifies #{primary_key} primary key for Post class" do 30 | expect(subject.identify_primary_key([primary_key], 'Post')).to eq(primary_key) 31 | end 32 | end 33 | 34 | it 'returns nil if nothing is identifiable' do 35 | expect { subject.identify_primary_key(['foo_id'], 'Post') }.to raise_error(Neo4Apis::TableResolver::UnfoundPrimaryKeyError) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'neo4apis-activerecord' 2 | 3 | module Helpers 4 | def clear_neo4j(neo4j_connection, neo4j_url) 5 | neo4j_connection.query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r') 6 | 7 | # Clear constraints / indexes 8 | conn = Faraday.new(url: neo4j_url) 9 | response = conn.get('/db/data/schema/constraint/') 10 | JSON.parse(response.body).each do |constraint| 11 | Neo4j::Session.query("DROP CONSTRAINT ON (label:`#{constraint['label']}`) ASSERT label.#{constraint['property_keys'].first} IS UNIQUE") 12 | end 13 | 14 | JSON.parse(conn.get('/db/data/schema/index/').body).each do |index| 15 | Neo4j::Session.query("DROP INDEX ON :`#{index['label']}`(#{index['property_keys'].first})") 16 | end 17 | end 18 | end 19 | 20 | RSpec.configure do |c| 21 | c.include Helpers 22 | 23 | ActiveRecord::Migration.verbose = false 24 | end 25 | -------------------------------------------------------------------------------- /test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo4jrb/neo4apis-activerecord/6dd3bb2ab770dccf44cbea0dc5544743fd8af4e3/test.db --------------------------------------------------------------------------------