├── .agignore ├── .java-version ├── spec ├── unit │ ├── config.yml │ ├── auto_load_test_a.rb │ ├── auto_load_test_b.rb │ ├── paginated_spec.rb │ ├── relationship │ │ ├── initialize_spec.rb │ │ ├── relationship_spec.rb │ │ ├── rel_wrapper_spec.rb │ │ └── callbacks_spec.rb │ ├── migrations │ │ ├── migration_file_spec.rb │ │ └── check_pending_spec.rb │ ├── node │ │ ├── node_list_formatter_spec.rb │ │ ├── scope_eval_context_spec.rb │ │ ├── initialize_spec.rb │ │ ├── query_spec.rb │ │ ├── validation_spec.rb │ │ └── persistance_spec.rb │ ├── load_hooks_spec.rb │ ├── shared │ │ ├── query_factory_spec.rb │ │ ├── mass_assignment_spec.rb │ │ ├── rel_type_converters_spec.rb │ │ ├── type_converters_spec.rb │ │ └── filtered_hash_spec.rb │ ├── schema │ │ └── operation_spec.rb │ └── attribute_set_spec.rb ├── e2e │ ├── version_spec.rb │ ├── transaction_spec.rb │ ├── railtie_spec.rb │ ├── active_graph-core_spec.rb │ ├── typecasting_spec.rb │ ├── model_schema_spec.rb │ ├── marshal_spec.rb │ ├── queries_spec.rb │ ├── reflections_spec.rb │ ├── validation_association_spec.rb │ ├── basic_model_spec.rb │ ├── generators_spec.rb │ ├── wrapped_transactions_spec.rb │ └── attributes_spec.rb ├── shared_examples │ ├── non-updatable_model.rb │ ├── loadable_model.rb │ ├── updatable_model.rb │ ├── uncreatable_model.rb │ ├── destroyable_model.rb │ ├── new_model.rb │ ├── creatable_model.rb │ ├── unsavable_model.rb │ ├── schema │ │ └── schema_operation_interface_unit.rb │ ├── scopable_model.rb │ ├── saveable_model.rb │ ├── forbidden_attributes_shared_examples.rb │ └── after_commit.rb ├── migration_files │ ├── transactional_migrations │ │ ├── 8888888888_add_a_constraint.rb │ │ ├── 0000000000_schema_and_data_update.rb │ │ ├── 9999999999_schema_and_data_update_without_transactions.rb │ │ ├── 1231231231_failing_migration.rb │ │ └── 1231231232_failing_migration_without_transactions.rb │ └── migrations │ │ ├── 1234567890_rename_john_jack.rb │ │ ├── 9500000000_rename_jack_bob.rb │ │ └── 9500000001_rename_bob_frank.rb ├── README.md ├── support │ └── matchers.rb ├── unique_class.rb ├── active_graph │ └── core │ │ ├── cypher_error_spec.rb │ │ ├── query_parameters_spec.rb │ │ └── wrappable_spec.rb ├── integration │ └── orm_adapter │ │ └── neo4j_spec.rb └── neo4j_spec_helpers.rb ├── .rspec ├── .codeclimate.yml ├── e2e_tests ├── .e2e_rspec ├── spec_helper.rb ├── migration_generator_spec.rb ├── model_generator_spec.rb └── setup.sh ├── lib ├── active_graph │ ├── version.rb │ ├── node │ │ ├── callbacks.rb │ │ ├── dependent.rb │ │ ├── rels.rb │ │ ├── node_list_formatter.rb │ │ ├── labels │ │ │ ├── reloading.rb │ │ │ └── index.rb │ │ ├── query │ │ │ └── query_proxy_find_in_batches.rb │ │ ├── initialize.rb │ │ ├── enum.rb │ │ ├── has_n │ │ │ └── association │ │ │ │ ├── rel_wrapper.rb │ │ │ │ └── rel_factory.rb │ │ ├── dependent_callbacks.rb │ │ ├── wrapping.rb │ │ ├── unpersisted.rb │ │ ├── dependent │ │ │ └── association_methods.rb │ │ ├── id_property │ │ │ └── accessor.rb │ │ ├── orm_adapter.rb │ │ ├── property.rb │ │ └── validations.rb │ ├── relationship │ │ ├── validations.rb │ │ ├── callbacks.rb │ │ ├── wrapping.rb │ │ └── initialize.rb │ ├── timestamps.rb │ ├── core │ │ ├── entity.rb │ │ ├── node.rb │ │ ├── schema_errors.rb │ │ ├── query_builder.rb │ │ ├── wrappable.rb │ │ ├── result.rb │ │ ├── record.rb │ │ ├── cypher_error.rb │ │ ├── querable.rb │ │ ├── query_ext.rb │ │ ├── instrumentable.rb │ │ ├── logging.rb │ │ └── query_find_in_batches.rb │ ├── timestamps │ │ ├── created.rb │ │ └── updated.rb │ ├── ansi.rb │ ├── secure_random_ext.rb │ ├── type_converters.rb │ ├── migrations │ │ ├── schema_migration.rb │ │ ├── check_pending.rb │ │ ├── migration_file.rb │ │ ├── schema.rb │ │ ├── helpers │ │ │ └── relationships.rb │ │ └── base.rb │ ├── transaction.rb │ ├── shared │ │ ├── node_query_factory.rb │ │ ├── marshal.rb │ │ ├── identity.rb │ │ ├── permitted_attributes.rb │ │ ├── cypher.rb │ │ ├── serialized_properties.rb │ │ ├── declared_property │ │ │ └── index.rb │ │ ├── rel_query_factory.rb │ │ ├── validations.rb │ │ ├── typecaster.rb │ │ ├── query_factory.rb │ │ ├── mass_assignment.rb │ │ ├── callbacks.rb │ │ ├── rel_type_converters.rb │ │ └── filtered_hash.rb │ ├── migrations.rb │ ├── attribute_set.rb │ ├── paginated.rb │ ├── lazy_attribute_hash.rb │ ├── shared.rb │ ├── class_arguments.rb │ ├── undeclared_properties.rb │ ├── error.rb │ ├── base.rb │ ├── transactions.rb │ └── relationship.rb ├── rails │ └── generators │ │ ├── active_graph │ │ ├── migration │ │ │ ├── templates │ │ │ │ └── migration.erb │ │ │ └── migration_generator.rb │ │ ├── model │ │ │ ├── templates │ │ │ │ ├── migration.erb │ │ │ │ └── model.erb │ │ │ └── model_generator.rb │ │ └── upgrade_v8 │ │ │ ├── templates │ │ │ └── migration.erb │ │ │ └── upgrade_v8_generator.rb │ │ └── source_path_helper.rb └── active_graph.rb ├── .yardopts ├── PULL_REQUEST_TEMPLATE.md ├── docs ├── _exts │ └── hidden_code_block.pyc ├── _yard │ └── custom_templates │ │ └── default │ │ └── fulldoc │ │ ├── rst │ │ ├── index.erb │ │ └── module.erb │ │ └── setup.rb ├── assets │ └── neo4jrb.css ├── activegraph.rb ├── AdditionalResources.rst ├── _templates │ └── layout.html ├── README.md ├── QueryClauseMethods.rst.base ├── HelperGems.rst └── index.rst ├── config ├── locales │ └── en.yml └── neo4j │ ├── add_classnames.yml │ └── config.yml ├── mkdocs.yml ├── ISSUE_TEMPLATE.md ├── CONTRIBUTORS ├── bin └── rake ├── Gemfile ├── .overcommit.yml ├── Guardfile ├── Dockerfile ├── .gitignore ├── LICENSE ├── Rakefile ├── .github └── workflows │ ├── e2e_test.yml │ └── test.yml ├── activegraph.gemspec └── .rubocop.yml /.agignore: -------------------------------------------------------------------------------- 1 | *.rst 2 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17.0 2 | -------------------------------------------------------------------------------- /spec/unit/config.yml: -------------------------------------------------------------------------------- 1 | my_conf: My value -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --tty 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | plugins: 3 | rubocop: 4 | enabled: true 5 | -------------------------------------------------------------------------------- /e2e_tests/.e2e_rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --tty 3 | --require ./e2e_tests/spec_helper.rb 4 | -------------------------------------------------------------------------------- /lib/active_graph/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | VERSION = '11.5.0.beta.3' 3 | end 4 | -------------------------------------------------------------------------------- /spec/unit/auto_load_test_a.rb: -------------------------------------------------------------------------------- 1 | module AutoLoadTest 2 | class MyClass 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'Neo4j.rb API Documentation' 2 | --no-private 3 | --output-dir=docs/_build/_yard 4 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | This pull introduces/changes: 4 | * 5 | * 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/_exts/hidden_code_block.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo4jrb/activegraph/HEAD/docs/_exts/hidden_code_block.pyc -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | errors: 3 | messages: 4 | nil: "can't be nil" 5 | taken: "has already been taken" 6 | -------------------------------------------------------------------------------- /spec/e2e/version_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'ActiveGraph::VERSION' do 2 | it 'is defined' do 3 | expect(ActiveGraph::VERSION).to be_a(String) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/unit/auto_load_test_b.rb: -------------------------------------------------------------------------------- 1 | module AutoLoadTest 2 | class MyWrapperClass 3 | include ActiveGraph::Node 4 | property :some_prop 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /docs/_yard/custom_templates/default/fulldoc/rst/index.erb: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. toctree:: 5 | 6 | <% @toc.each do |entry| %> 7 | <%= entry %> 8 | <% end %> 9 | 10 | -------------------------------------------------------------------------------- /lib/active_graph/node/callbacks.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Callbacks #:nodoc: 4 | extend ActiveSupport::Concern 5 | include ActiveGraph::Shared::Callbacks 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/shared_examples/non-updatable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'non-updatable model' do 2 | context 'then' do 3 | it "shouldn't update" do 4 | expect(subject.update(a: 3)).not_to be true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/active_graph/relationship/validations.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Relationship 3 | module Validations 4 | extend ActiveSupport::Concern 5 | include ActiveGraph::Shared::Validations 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/active_graph/timestamps.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | # This mixin includes timestamps in the included class 3 | module Timestamps 4 | extend ActiveSupport::Concern 5 | include Created 6 | include Updated 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/active_graph/core/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveGraph 4 | module Core 5 | module Entity 6 | def properties 7 | @properties ||= super 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /docs/assets/neo4jrb.css: -------------------------------------------------------------------------------- 1 | .contents.local.topic, 2 | .toctree-wrapper.compound { 3 | float: right; 4 | padding: 0.5em; 5 | background: #EEE; 6 | border: 1px solid #888; 7 | } 8 | 9 | .toctree-wrapper.compound { 10 | width: 40% 11 | } 12 | -------------------------------------------------------------------------------- /lib/active_graph/node/dependent.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Dependent 4 | def dependent_children 5 | @dependent_children ||= [] 6 | end 7 | 8 | attr_writer :called_by 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Neo4j.rb 2 | site_url: http://neo4jrb.io/ 3 | repo_url: https://github.com/neo4jrb/neo4j 4 | site_description: An active model wrapper for the Neo4j Graph Database for Ruby. 5 | pages: 6 | - ['index.md', 'Introduction'] 7 | 8 | site_dir: docs_site 9 | -------------------------------------------------------------------------------- /spec/migration_files/transactional_migrations/8888888888_add_a_constraint.rb: -------------------------------------------------------------------------------- 1 | class AddAConstraint < ActiveGraph::Migrations::Base 2 | def up 3 | add_constraint :Book, :some 4 | end 5 | 6 | def down 7 | drop_constraint :Book, :some 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_graph/core/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveGraph 4 | module Core 5 | module Node 6 | def neo_id 7 | id 8 | end 9 | 10 | def labels 11 | @labels ||= super 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/active_graph/timestamps/created.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Timestamps 3 | # This mixin includes a created_at timestamp property 4 | module Created 5 | extend ActiveSupport::Concern 6 | included { property :created_at } 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_graph/timestamps/updated.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Timestamps 3 | # This mixin includes a updated_at timestamp property 4 | module Updated 5 | extend ActiveSupport::Concern 6 | included { property :updated_at } 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/migration/templates/migration.erb: -------------------------------------------------------------------------------- 1 | class <%= @migration_class_name.underscore.camelize %> < ActiveGraph::Migrations::Base 2 | def up 3 | <%= @content %> 4 | end 5 | 6 | def down 7 | raise ActiveGraph::IrreversibleMigration 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_graph/ansi.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module ANSI 3 | CLEAR = "\e[0m" 4 | BOLD = "\e[1m" 5 | 6 | RED = "\e[31m" 7 | GREEN = "\e[32m" 8 | YELLOW = "\e[33m" 9 | BLUE = "\e[34m" 10 | MAGENTA = "\e[35m" 11 | CYAN = "\e[36m" 12 | WHITE = "\e[37m" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/active_graph/secure_random_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveGraph 4 | module SecureRandomExt 5 | def hex(n = nil) 6 | super.force_encoding(Encoding::UTF_8) 7 | end 8 | 9 | def uuid 10 | super.force_encoding(Encoding::UTF_8) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Additional information which could be helpful if relevant to your issue: 8 | 9 | ### Code example (inline, gist, or repo) 10 | 11 | 12 | 13 | ### Runtime information: 14 | 15 | Neo4j database version: 16 | `neo4j` gem version: 17 | `neo4j-core` gem version: 18 | 19 | -------------------------------------------------------------------------------- /lib/active_graph/type_converters.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module TypeConverters 3 | # This exists for legacy purposes. Some gems that the Neo4jrb project does not own 4 | # may contain references to this file. We will remove it once that has been dealt with. 5 | include ActiveGraph::Shared::TypeConverters 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/migration_files/transactional_migrations/0000000000_schema_and_data_update.rb: -------------------------------------------------------------------------------- 1 | class SchemaAndDataUpdate < ActiveGraph::Migrations::Base 2 | def up 3 | add_constraint :Book, :isbn 4 | execute 'CREATE (n:`Contact` {phone: "123123"})' 5 | end 6 | 7 | def down 8 | fail ActiveGraph::IrreversibleMigration 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/model/templates/migration.erb: -------------------------------------------------------------------------------- 1 | class Create<%= @migration_class_name.underscore.camelize %> < ActiveGraph::Migrations::Base 2 | disable_transactions! 3 | 4 | def up 5 | add_constraint :<%= class_name %>, :uuid 6 | end 7 | 8 | def down 9 | drop_constraint :<%= class_name %>, :uuid 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/migration_files/migrations/1234567890_rename_john_jack.rb: -------------------------------------------------------------------------------- 1 | class RenameJohnJack < ActiveGraph::Migrations::Base 2 | def up 3 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 4 | name: 'John', new_name: 'Jack' 5 | end 6 | 7 | def down 8 | fail ActiveGraph::IrreversibleMigration 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/unit/paginated_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Paginated do 2 | describe 'initialize' do 3 | it 'sets instance variables @items, @total, @current_page' do 4 | a = ActiveGraph::Paginated.new(5, 10, 15) 5 | %w(@items @total @current_page).each { |i| expect(a.instance_variable_defined?(i.to_sym)).to be_truthy } 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/active_graph/core/schema_errors.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module SchemaErrors 4 | class ConstraintValidationFailedError < CypherError; 5 | end 6 | class ConstraintAlreadyExistsError < CypherError; 7 | end 8 | class IndexAlreadyExistsError < CypherError; 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_graph/node/rels.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node 2 | module Rels 3 | extend Forwardable 4 | def_delegators :_rels_delegator, :rel?, :rel, :rels, :node, :nodes, :create_rel 5 | 6 | def _rels_delegator 7 | fail "Can't access relationship on a non persisted node" unless _persisted_obj 8 | _persisted_obj 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Maintainers: 2 | Heinrich Klobuczek 3 | Amit Suryanvanshi 4 | 5 | Previous Maintainers: 6 | Chris Grigg 7 | Brian Underwood 8 | 9 | Creator: 10 | Andreas Ronge 11 | 12 | See: https://github.com/neo4jrb/neo4j/graphs/contributors 13 | -------------------------------------------------------------------------------- /spec/shared_examples/loadable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'loadable model' do 2 | context 'when saved' do 3 | before :each do 4 | subject.save 5 | end 6 | 7 | it 'should load_entity a previously stored node' do 8 | result = subject.class.find(subject.id) 9 | expect(result).to eq(subject) 10 | expect(result).to be_persisted 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/relationship/initialize_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Relationship::Initialize do 2 | let(:clazz) do 3 | Class.new do 4 | include ActiveGraph::Relationship::Initialize 5 | end 6 | end 7 | 8 | describe 'init_on_load' 9 | describe 'wrapper' do 10 | it 'returns self' do 11 | r = clazz.new 12 | expect(r.wrapper).to eq r 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails/generators/source_path_helper.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Generators::SourcePathHelper 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def source_root 6 | @_neo4j_source_root ||= File.expand_path(File.join(File.dirname(__FILE__), 7 | 'active_graph', generator_name, 'templates')) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/schema_migration.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | class SchemaMigration 4 | include ActiveGraph::Node 5 | id_property :migration_id 6 | property :migration_id, type: String 7 | property :incomplete, type: Boolean 8 | 9 | def <=>(other) 10 | migration_id <=> other.migration_id 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /e2e_tests/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_graph' 2 | require 'find' 3 | 4 | server_url = ENV['NEO4J_URL'] || 'bolt://localhost:7687' 5 | ActiveGraph::Base.driver = Neo4j::Driver::GraphDatabase.driver(server_url, Neo4j::Driver::AuthTokens.basic('neo4j', 'password')) 6 | 7 | def load_migration(suffix) 8 | Find.find('myapp/db/neo4j/migrate') do |path| 9 | load path if path =~ /.*#{suffix}$/ 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/migration_files/transactional_migrations/9999999999_schema_and_data_update_without_transactions.rb: -------------------------------------------------------------------------------- 1 | class SchemaAndDataUpdateWithoutTransactions < ActiveGraph::Migrations::Base 2 | disable_transactions! 3 | 4 | def up 5 | add_constraint :Book, :isbn 6 | execute 'CREATE (n:`Contact` {phone: "123123"})' 7 | end 8 | 9 | def down 10 | fail ActiveGraph::IrreversibleMigration 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_graph/node/node_list_formatter.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node 2 | class NodeListFormatter 3 | def initialize(list, max_elements = 5) 4 | @list = list 5 | @max_elements = max_elements 6 | end 7 | 8 | def inspect 9 | return @list.inspect if !@max_elements || @list.length <= @max_elements 10 | "[#{@list.take(5).map!(&:inspect).join(', ')}, ...]" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/migration_files/migrations/9500000000_rename_jack_bob.rb: -------------------------------------------------------------------------------- 1 | class RenameJackBob < ActiveGraph::Migrations::Base 2 | def up 3 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 4 | name: 'Jack', new_name: 'Bob' 5 | end 6 | 7 | def down 8 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 9 | name: 'Bob', new_name: 'Jack' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/migration_files/transactional_migrations/1231231231_failing_migration.rb: -------------------------------------------------------------------------------- 1 | class FailingMigration < ActiveGraph::Migrations::Base 2 | def up 3 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 4 | name: 'Joe', new_name: 'Jack' 5 | execute 'CREATE (n:`Contact` {phone: "123123"})' 6 | end 7 | 8 | def down 9 | fail ActiveGraph::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/migration_files/migrations/9500000001_rename_bob_frank.rb: -------------------------------------------------------------------------------- 1 | class RenameBobFrank < ActiveGraph::Migrations::Base 2 | def up 3 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 4 | name: 'Bob', new_name: 'Frank' 5 | end 6 | 7 | def down 8 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 9 | name: 'Frank', new_name: 'Bob' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/shared_examples/updatable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'updatable model' do 2 | context 'when saved' do 3 | before { subject.save! } 4 | 5 | context 'and updated' do 6 | it 'should have altered attributes' do 7 | expect { subject.update!(a: 1, b: 2) }.not_to raise_error 8 | expect(subject[:a]).to eq(1) 9 | expect(subject[:b]).to eq(2) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/neo4j/add_classnames.yml: -------------------------------------------------------------------------------- 1 | # This file is used as the source for adding _classname properties to nodes and relationships. # Within the relationship array, the from/to labels are optional but "type" is not. # # nodes: # add: [Lesson, Student, Exam] # overwrite: [Teacher] # relationships: # add: # StudentLesson: { from: 'Student', to: 'Lesson', type: 'enrolled_in' } # overwrite: # ExamLesson: { type: 'has_this_thing' } -------------------------------------------------------------------------------- /spec/shared_examples/uncreatable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'uncreatable model' do 2 | context 'when attempting to create' do 3 | it "shouldn't create ok" do 4 | expect(subject.class.create(subject.attributes).persisted?).not_to be true 5 | end 6 | 7 | it 'should raise an exception on #create!' do 8 | expect { subject.class.create!(subject.attributes) }.to raise_error ActiveGraph::Node::Persistence::RecordInvalidError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/active_graph/transaction.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Transaction 3 | def rollback 4 | super 5 | @rolled_back = true 6 | end 7 | 8 | def after_commit(&block) 9 | after_commit_registry << block 10 | end 11 | 12 | def apply_callbacks 13 | after_commit_registry.each(&:call) unless @rolled_back 14 | end 15 | 16 | private 17 | 18 | def after_commit_registry 19 | @after_commit_registry ||= [] 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | 3 | The specs are configured to run against `bolt://localhost:7687` by default. In order to change this in your local setup you can set the `NEO4J_URL` environment variable on your system to suit your needs. 4 | 5 | To make this easier, the neo4j spec suite allows you to add a `.env` file locally to configure this. For example, to set the `NEO4J_URL` you simply need to add a `.env` file that looks like this: 6 | 7 | ``` 8 | NEO4J_URL=bolt://localhost:6998 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rake' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require 'pathname' 11 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require 'rubygems' 15 | require 'bundler/setup' 16 | 17 | load Gem.bin_path('rake', 'rake') 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | 5 | active_model_version = ENV['ACTIVE_MODEL_VERSION'] 6 | gem 'activemodel', "~> #{active_model_version}" if active_model_version&.length&.positive? 7 | 8 | group 'test' do 9 | gem 'coveralls', require: false 10 | gem 'overcommit' 11 | gem 'codecov', require: false 12 | gem 'simplecov', require: false 13 | gem 'simplecov-html', require: false 14 | gem 'its' 15 | gem 'test-unit' 16 | gem 'colored' 17 | gem 'dotenv' 18 | gem 'timecop' 19 | end 20 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/migration/migration_generator.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../migration_helper' 2 | require_relative '../../source_path_helper' 3 | 4 | module ActiveGraph 5 | module Generators 6 | class MigrationGenerator < ::Rails::Generators::NamedBase 7 | include ActiveGraph::Generators::SourcePathHelper 8 | include ActiveGraph::Generators::MigrationHelper 9 | 10 | def create_migration_file 11 | migration_template 'migration.erb' 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/active_graph/shared/node_query_factory.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | class NodeQueryFactory < QueryFactory 3 | protected 4 | 5 | def match_string 6 | "(#{identifier})" 7 | end 8 | 9 | def create_query 10 | return match_query if graph_object.persisted? 11 | labels = graph_object.labels_for_create.map { |l| ":`#{l}`" }.join 12 | base_query.create("(#{identifier}#{labels} $#{identifier}_params)").params(identifier_params => graph_object.props_for_create) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | 2 | PreCommit: 3 | Rubocop: 4 | enabled: true 5 | on_warn: fail # Treat all warnings as failures 6 | 7 | TrailingWhitespace: 8 | enabled: true 9 | exclude: 10 | - '**/db/structure.sql' # Ignore trailing whitespace in generated files 11 | 12 | PostCheckout: 13 | ALL: # Special hook name that customizes all hooks of this type 14 | quiet: true # Change all post-checkout hooks to only display output on failure 15 | 16 | IndexTags: 17 | enabled: true # Generate a tags file with `ctags` each time HEAD changes 18 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | # :nocov: 2 | RSpec::Matchers.define :have_error_on do |*attributes| 3 | @message = nil 4 | all_attributes = [attributes] 5 | 6 | chain :or do |*or_attributes| 7 | all_attributes << or_attributes 8 | end 9 | 10 | match do |model| 11 | model.valid? 12 | @has_errors = all_attributes.detect { |attribute| model.errors[attribute[0]].present? } 13 | if @message 14 | !!@has_errors && model.errors[@has_errors[0]].include?(@has_errors[1]) 15 | else 16 | !!@has_errors 17 | end 18 | end 19 | end 20 | # :nocov: 21 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | guard :rubocop, cli: '--auto-correct --display-cop-names --except Lint/Debugger' do 4 | watch(/.+\.rb$/) 5 | watch(%r{(?:.+/)?\.rubocop.*\.yml$}) { |m| File.dirname(m[0]) } 6 | 7 | callback(:start_begin) { puts '👮 🚨 👮 🚨 👮 🚨 👮 🚨 👮 ' } 8 | end 9 | 10 | guard :rspec, cmd: 'bundle exec rspec', failed_mode: :focus do 11 | watch(%r{^spec/.+_spec\.rb$}) 12 | watch(%r{^lib/(.+)\.rb}) { |m| "spec/lib/#{m[1]}_spec.rb" } 13 | watch('spec/spec_helper.rb') { 'spec' } 14 | end 15 | -------------------------------------------------------------------------------- /lib/active_graph/core/query_builder.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | class QueryBuilder 4 | Query = Struct.new(:cypher, :parameters, :pretty_cypher, :context) 5 | 6 | def self.query(*args) 7 | case args.map(&:class) 8 | when [String], [String, Hash] 9 | Query.new(args[0], args[1] || {}) 10 | when [::ActiveGraph::Core::Query] 11 | args[0] 12 | else 13 | fail ArgumentError, "Could not determine query from arguments: #{args.inspect}" 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/shared_examples/destroyable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'destroyable model' do 2 | context 'when saved' do 3 | before :each do 4 | subject.save! 5 | @other = subject.class.find_by_id(subject.id) 6 | @old_id = subject.id 7 | @result = subject.destroy 8 | end 9 | it { is_expected.to be_frozen } 10 | 11 | it 'should remove the model from the database' do 12 | expect(subject.class.find_by_id(@old_id)).to be_nil 13 | end 14 | 15 | it 'returns the model object' do 16 | expect(@result).to eq(subject) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jruby:latest 2 | 3 | RUN apt-get update && \ 4 | DEBIAN_FRONTEND=noninteractive apt-get install -y locales && \ 5 | apt-get install -y build-essential libjson-c-dev && \ 6 | apt-get clean -y 7 | RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen 8 | 9 | WORKDIR /usr/src/app/ 10 | 11 | RUN gem install bundler -v '~> 2' 12 | COPY Gemfile* activegraph.gemspec ./ 13 | COPY lib/active_graph/version.rb ./lib/active_graph/ 14 | RUN bundle install 15 | 16 | ADD . ./ 17 | 18 | ENV LANG en_US.UTF-8 19 | ENV LANGUAGE en_US.UTF-8 20 | ENV LC_ALL en_US.UTF-8 21 | -------------------------------------------------------------------------------- /lib/active_graph/shared/marshal.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | module Marshal 4 | extend ActiveSupport::Concern 5 | 6 | def marshal_dump 7 | marshal_instance_variables.map(&method(:instance_variable_get)) 8 | end 9 | 10 | def marshal_load(array) 11 | marshal_instance_variables.zip(array).each do |var, value| 12 | instance_variable_set(var, value) 13 | end 14 | end 15 | 16 | private 17 | 18 | def marshal_instance_variables 19 | self.class::MARSHAL_INSTANCE_VARIABLES 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/upgrade_v8/templates/migration.erb: -------------------------------------------------------------------------------- 1 | class <%= @migration_class_name.underscore.camelize %> < ActiveGraph::Migrations::Base 2 | def up 3 | <% @schema.each do |type, data| 4 | data.each do |element| %> 5 | add_<%= type %> <%= element[:label].inspect %>, <%= element[:property_name].inspect %>, force: true 6 | <% end 7 | end %> 8 | end 9 | 10 | def down 11 | <% @schema.each do |type, data| 12 | data.each do |element| %> 13 | drop_<%= type %> <%= element[:label].inspect %>, <%= element[:property_name].inspect %> 14 | <% end 15 | end %> 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/unit/migrations/migration_file_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Migrations::MigrationFile do 2 | let(:file_name) do 3 | "#{Rails.root}/spec/migration_files/transactional_migrations/1231231232_failing_migration_without_transactions.rb" 4 | end 5 | subject { described_class.new(file_name) } 6 | 7 | its(:version) { is_expected.to eq('1231231232') } 8 | its(:symbol_name) { is_expected.to eq('failing_migration_without_transactions') } 9 | its(:class_name) { is_expected.to eq('FailingMigrationWithoutTransactions') } 10 | its(:create) { is_expected.to be_a(FailingMigrationWithoutTransactions) } 11 | end 12 | -------------------------------------------------------------------------------- /e2e_tests/migration_generator_spec.rb: -------------------------------------------------------------------------------- 1 | load_migration('blah_migration.rb') 2 | 3 | describe 'Migration Generator' do 4 | describe 'generated migration file' do 5 | it 'inherits from ActiveGraph::Migrations::Base' do 6 | expect(BlahMigration).to be < ActiveGraph::Migrations::Base 7 | end 8 | 9 | it 'defines up method' do 10 | migration = CreateUser.new(nil) 11 | expect(migration.method(:up)).to be_present 12 | end 13 | 14 | it 'defines down method' do 15 | migration = CreateUser.new(nil) 16 | expect(migration.method(:up)).to be_present 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/check_pending.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | class CheckPending 4 | def initialize(app) 5 | @app = app 6 | @last_check = 0 7 | end 8 | 9 | def call(env) 10 | latest_migration = ActiveGraph::Migrations::Runner.latest_migration 11 | mtime = latest_migration ? latest_migration.version.to_i : 0 12 | if @last_check < mtime 13 | ActiveGraph::Migrations.check_for_pending_migrations! 14 | @last_check = mtime 15 | end 16 | @app.call(env) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/active_graph/node/labels/reloading.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node::Labels 2 | module Reloading 3 | extend ActiveSupport::Concern 4 | 5 | MODELS_TO_RELOAD = [] 6 | 7 | def self.reload_models! 8 | MODELS_TO_RELOAD.each(&:constantize) 9 | MODELS_TO_RELOAD.clear 10 | end 11 | 12 | module ClassMethods 13 | def before_remove_const 14 | associations.each_value(&:queue_model_refresh!) 15 | MODELS_FOR_LABELS_CACHE.clear 16 | WRAPPED_CLASSES.each { |c| MODELS_TO_RELOAD << c.name } 17 | WRAPPED_CLASSES.clear 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/unit/node/node_list_formatter_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node 2 | describe NodeListFormatter do 3 | let(:max_elements) { 5 } 4 | 5 | subject { described_class.new(list, max_elements) } 6 | 7 | context 'when the list length is greater than `max_elements`' do 8 | let(:list) { (0...10).to_a } 9 | 10 | its(:inspect) { is_expected.to eq '[0, 1, 2, 3, 4, ...]' } 11 | end 12 | 13 | context 'when the list length is less or equal than `max_elements`' do 14 | let(:list) { (0...5).to_a } 15 | 16 | its(:inspect) { is_expected.to eq '[0, 1, 2, 3, 4]' } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/active_graph/migrations.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | class << self 4 | def check_for_pending_migrations! 5 | return if ActiveGraph::Config.configuration['skip_migration_check'] 6 | 7 | runner = ActiveGraph::Migrations::Runner.new 8 | pending = runner.pending_migrations 9 | fail ::ActiveGraph::PendingMigrationError, pending if pending.any? 10 | end 11 | 12 | attr_accessor :currently_running_migrations 13 | 14 | def maintain_test_schema! 15 | ActiveGraph::Migrations::Runner.new(silenced: true).all 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/migration_files/transactional_migrations/1231231232_failing_migration_without_transactions.rb: -------------------------------------------------------------------------------- 1 | class FailingMigrationWithoutTransactions < ActiveGraph::Migrations::Base 2 | disable_transactions! 3 | 4 | def up 5 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 6 | name: 'Joe', new_name: 'Jack' 7 | execute 'CREATE (n:`Contact` {phone: "123123"})' 8 | end 9 | 10 | def down 11 | execute 'MATCH (n:`Contact` {phone: "123123"}) DELETE n' 12 | execute 'MATCH (u:`User`) WHERE u.name = $name SET u.name = $new_name', 13 | name: 'Jim', new_name: 'John' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore netbeans specific stuff 2 | nbproject 3 | 4 | # Can we agree to install binstubs as `bundle install --binstubs=b`? 5 | b/ 6 | 7 | # ignore Rubymine 8 | .idea 9 | 10 | .rvmrc 11 | .bundle 12 | /tmp/ 13 | /db/ 14 | /pkg/ 15 | /doc 16 | /target/ 17 | /neo4j/ 18 | /example/blog/neo4j-db 19 | /example/blog/log 20 | .yardoc 21 | # jedit 22 | *~ 23 | .ruby-version 24 | .ruby-gemset 25 | .jrubyrc 26 | 27 | # vim 28 | .*.sw[a-z] 29 | 30 | Gemfile.lock 31 | coverage 32 | 33 | docs_site/ 34 | /docs/_build 35 | /docs/api 36 | 37 | # dotenv 38 | .env 39 | 40 | # Mac 41 | .DS_Store 42 | 43 | # Vagrant 44 | .vagrant/* 45 | ubuntu* 46 | -------------------------------------------------------------------------------- /spec/unit/relationship/relationship_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Relationship do 2 | let(:clazz) do 3 | Class.new do 4 | def self.name 5 | 'Clazz' 6 | end 7 | 8 | include ActiveGraph::Relationship 9 | end 10 | end 11 | 12 | it 'can be included in a module' do 13 | expect { clazz.new }.not_to raise_error 14 | end 15 | 16 | describe 'neo4j_obj' do 17 | context 'on a non-persisted node' do 18 | it 'raises an error' do 19 | expect { clazz.new.neo4j_obj }.to raise_error(/Tried to access native neo4j object on a non persisted object/) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/active_graph/node/query/query_proxy_find_in_batches.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Query 4 | module QueryProxyFindInBatches 5 | def find_in_batches(options = {}) 6 | query.return(identity).find_in_batches(identity, @model.primary_key, options) do |batch| 7 | yield batch.map { |record| record[identity] } 8 | end 9 | end 10 | 11 | def find_each(options = {}) 12 | query.return(identity).find_each(identity, @model.primary_key, options) do |result| 13 | yield result[identity] 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/node/scope_eval_context_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Scope::ScopeEvalContext do 2 | describe 'method missing' do 3 | let(:query_proxy) { double('QueryProxy', query: double) } 4 | subject { described_class.new(nil, query_proxy) } 5 | 6 | it 'should delegate non existant method call to query_proxy' do 7 | expect(query_proxy).to receive(:query) 8 | subject.query 9 | end 10 | 11 | it 'should call method_missing of query_proxy in case method does not exist on query_proxy' do 12 | expect(query_proxy).to receive(:method_missing).with(:non_existent_method) 13 | subject.non_existent_method 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/migration_file.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | class MigrationFile 4 | attr_reader :file_name, :symbol_name, :class_name, :version 5 | 6 | def initialize(file_name) 7 | @file_name = file_name 8 | extract_data! 9 | end 10 | 11 | def create(options = {}) 12 | require @file_name 13 | class_name.constantize.new(@version, options) 14 | end 15 | 16 | private 17 | 18 | def extract_data! 19 | @version, @symbol_name = File.basename(@file_name, '.rb').split('_', 2) 20 | @class_name = @symbol_name.camelize 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/e2e/transaction_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'ActiveGraph::Transaction' do 2 | context 'reading has_one relationships for ActiveGraph::Server' do 3 | before do 4 | stub_node_class('Clazz') do 5 | property :name 6 | has_one :out, :thing, type: nil, model_class: self 7 | end 8 | end 9 | 10 | before { Clazz } 11 | 12 | it 'returns a wrapped node inside and outside of transaction' do 13 | a = nil 14 | b = nil 15 | ActiveGraph::Base.transaction do 16 | a = Clazz.create name: 'a' 17 | b = Clazz.create name: 'b' 18 | a.thing = b 19 | expect(a.thing).to eq b 20 | end 21 | expect(a.thing).to eq(b) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/unit/node/initialize_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Initialize do 2 | before do 3 | stub_node_class('MyModel') do 4 | property :name, type: String 5 | end 6 | end 7 | 8 | describe '@attributes' do 9 | let(:first_node) { MyModel.create(name: 'foo') } 10 | let(:attributes) { first_node.instance_variable_get(:@attributes) } 11 | let(:keys) { attributes.keys } 12 | 13 | it '@attributes are AttributeSet' do 14 | expect(attributes).to be_kind_of(ActiveGraph::AttributeSet) 15 | end 16 | 17 | it 'sets @attributes with the expected properties' do 18 | expect(keys).to eq(['name', ('uuid' unless MyModel.id_property_name == :neo_id)].compact) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/active_graph/core/wrappable.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module Wrappable 4 | extend ActiveSupport::Concern 5 | 6 | def wrap 7 | self.class.wrap(self) 8 | end 9 | 10 | class_methods do 11 | def wrapper_callback(&proc) 12 | fail 'Callback already specified!' if @wrapper_callback 13 | @wrapper_callback = proc 14 | end 15 | 16 | def clear_wrapper_callback 17 | @wrapper_callback = nil 18 | end 19 | 20 | def wrap(node) 21 | if @wrapper_callback 22 | @wrapper_callback.call(node) 23 | else 24 | node 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/active_graph/relationship/callbacks.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Relationship 3 | module Callbacks #:nodoc: 4 | extend ActiveSupport::Concern 5 | include ActiveGraph::Shared::Callbacks 6 | 7 | def save(*args) 8 | unless _persisted_obj || (from_node.respond_to?(:neo_id) && to_node.respond_to?(:neo_id)) 9 | fail ActiveGraph::Relationship::Persistence::RelInvalidError, 'from_node and to_node must be node objects' 10 | end 11 | super(*args) 12 | end 13 | 14 | def destroy 15 | to_node.callbacks_from_relationship(self, :in, from_node).try(:last) 16 | from_node.callbacks_from_relationship(self, :out, to_node).try(:last) 17 | super 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/model/templates/model.erb: -------------------------------------------------------------------------------- 1 | class <%= class_name %> <%= parent? ? "#{options[:parent].classify}" : "" %> 2 | include ActiveGraph::Node 3 | <% attributes.reject(&:reference?).each do |attribute| -%> 4 | property :<%= attribute.name %><%= ", type: #{attribute.type_class}" unless attribute.type_class == 'any' %><%= "\n " + index_fragment if index_fragment = index_fragment(attribute.name) %> 5 | <% end -%> 6 | 7 | <% attributes.select(&:reference?).each do |attribute| -%> 8 | has_one :in_or_out_or_both, :<%= attribute.name %>, type: :FILL_IN_RELATIONSHIP_TYPE_HERE 9 | <% end -%> 10 | 11 | <%= has_many_statements if has_many? -%> 12 | <%= has_one_statements if has_one? -%> 13 | 14 | <%= timestamp_statements if timestamps? -%> 15 | end 16 | -------------------------------------------------------------------------------- /spec/shared_examples/new_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'new model' do 2 | context 'when unsaved' do 3 | it { is_expected.not_to be_persisted } 4 | 5 | it 'should not allow write access to undeclared properties' do 6 | expect { subject[:unknown] = 'none' }.to raise_error(ActiveGraph::UnknownAttributeError) 7 | end 8 | 9 | it 'should not allow read access to undeclared properties' do 10 | expect(subject[:unknown]).to be_nil 11 | end 12 | 13 | it 'should allow access to all properties before it is saved' do 14 | expect(subject.props).to be_a(Hash) 15 | end 16 | 17 | it 'should allow properties to be accessed with a symbol' do 18 | expect { subject.props[:test] = true }.not_to raise_error 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/shared_examples/creatable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'creatable model' do 2 | context 'when attempting to create' do 3 | it 'should create ok' do 4 | expect(subject.class.create(subject.attributes)).to be_truthy 5 | end 6 | 7 | it 'should not raise an exception on #create!' do 8 | expect { subject.class.create!(subject.attributes) }.not_to raise_error 9 | end 10 | 11 | it 'should save the model and return it' do 12 | model = subject.class.create(subject.attributes) 13 | expect(model).to be_persisted 14 | end 15 | 16 | it 'should accept attributes to be set' do 17 | model = subject.class.create(subject.attributes.merge(name: 'Ben')) 18 | expect(model[:name]).to eq('Ben') 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/active_graph/node/initialize.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node::Initialize 2 | extend ActiveSupport::Concern 3 | include ActiveGraph::Shared::Initialize 4 | 5 | attr_reader :called_by 6 | 7 | # called when loading the node from the database 8 | # @param [ActiveGraph::Node] persisted_node the node this class wraps 9 | # @param [Hash] properties of the persisted node. 10 | def init_on_load(persisted_node, properties) 11 | self.class.extract_association_attributes!(properties) 12 | @_persisted_obj = persisted_node 13 | changed_attributes_clear! 14 | @attributes = convert_and_assign_attributes(properties) 15 | end 16 | 17 | def init_on_reload(reloaded) 18 | @attributes = nil 19 | init_on_load(reloaded, reloaded.properties) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/active_graph/node/enum.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node 2 | module Enum 3 | extend ActiveSupport::Concern 4 | include ActiveGraph::Shared::Enum 5 | 6 | module ClassMethods 7 | protected 8 | 9 | def define_property(property_name, *args) 10 | super 11 | ActiveGraph::ModelSchema.add_required_index(self, property_name) unless args[1][:_index] == false 12 | end 13 | 14 | def define_enum_methods(property_name, enum_keys, options) 15 | super 16 | define_enum_scopes(property_name, enum_keys) 17 | end 18 | 19 | def define_enum_scopes(property_name, enum_keys) 20 | enum_keys.each_key do |name| 21 | scope name, -> { where(property_name => name) } 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/active_graph/node/has_n/association/rel_wrapper.rb: -------------------------------------------------------------------------------- 1 | class ActiveGraph::Node::HasN::Association 2 | # Provides the interface needed to interact with the Relationship query factory. 3 | class RelWrapper 4 | include ActiveGraph::Shared::Cypher::RelIdentifiers 5 | include ActiveGraph::Shared::Cypher::CreateMethod 6 | 7 | attr_reader :type, :association 8 | attr_accessor :properties 9 | private :association 10 | alias props_for_create properties 11 | 12 | def initialize(association, properties = {}) 13 | @association = association 14 | @properties = properties 15 | @type = association.relationship_type(true) 16 | creates_unique(association.creates_unique_option) if association.unique? 17 | end 18 | 19 | def persisted? 20 | false 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/active_graph/attribute_set.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | class AttributeSet < ActiveModel::AttributeSet 3 | def initialize(attr_hash, attr_list) 4 | hashmap = ActiveGraph::LazyAttributeHash.new(attr_hash, attr_list) 5 | super(hashmap) 6 | end 7 | 8 | def method_missing(name, *args, **kwargs, &block) 9 | if defined?(name) 10 | attributes.send(:materialize).send(name, *args, **kwargs, &block) 11 | else 12 | super 13 | end 14 | end 15 | 16 | def respond_to_missing?(method, *) 17 | attributes.send(:materialize).respond_to?(method) || super 18 | end 19 | 20 | def keys 21 | attributes.send(:materialize).keys 22 | end 23 | 24 | def ==(other) 25 | other.is_a?(ActiveGraph::AttributeSet) ? super : to_hash == other 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unique_class.rb: -------------------------------------------------------------------------------- 1 | module UniqueClass 2 | @counter = 1 3 | 4 | class << self 5 | def _unique_random_number 6 | "#{Time.now.year}#{Time.now.to_i}#{Time.now.usec.to_s[0..2]}".to_i 7 | end 8 | 9 | def set(klass, name = nil) 10 | name ||= "Model_#{@counter}_#{_unique_random_number}" 11 | @counter += 1 12 | klass.class_eval <<-RUBY 13 | def self.to_s 14 | "#{name}" 15 | end 16 | RUBY 17 | # Object.send(:remove_const, name) if Object.const_defined?(name) 18 | Object.const_set(name, klass) # unless Kernel.const_defined?(name) 19 | klass 20 | end 21 | 22 | def create(class_name = nil, &block) 23 | clazz = Class.new 24 | set(clazz, class_name) 25 | clazz.class_eval(&block) if block 26 | clazz 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/unit/load_hooks_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'load hooks' do 2 | module HookedIn 3 | def hooked_in; end 4 | end 5 | 6 | [:node, :relationship].each do |mod| 7 | ActiveSupport.on_load(mod) do 8 | include HookedIn 9 | end 10 | end 11 | 12 | it 'fires callbacks for ActiveGraph::Node' do 13 | class ANLoadTest; end 14 | expect(ANLoadTest.new).not_to respond_to(:hooked_in) 15 | 16 | class ANLoadTest 17 | include ActiveGraph::Node 18 | end 19 | 20 | expect(ANLoadTest.new).to respond_to(:hooked_in) 21 | end 22 | 23 | it 'fires callbacks for ActiveGraph::Relationship' do 24 | class ARLoadTest; end 25 | expect(ARLoadTest.new).not_to respond_to(:hooked_in) 26 | 27 | class ARLoadTest 28 | include ActiveGraph::Relationship 29 | end 30 | 31 | expect(ARLoadTest.new).to respond_to(:hooked_in) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_graph/shared/identity.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | module Identity 4 | def ==(other) 5 | other.class == self.class && other.id == id 6 | end 7 | alias eql? == 8 | 9 | # Returns an Enumerable of all (primary) key attributes 10 | # or nil if model.persisted? is false 11 | def to_key 12 | _persisted_obj ? [id] : nil 13 | end 14 | 15 | # @return [Integer, nil] the neo4j id of the node if persisted or nil 16 | def neo_id 17 | _persisted_obj ? _persisted_obj.id : nil 18 | end 19 | 20 | def id 21 | if self.class.id_property_name 22 | send(self.class.id_property_name) 23 | else 24 | # Relationship 25 | neo_id 26 | end 27 | end 28 | 29 | def hash 30 | id.hash 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/active_graph/core/result.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module Result 4 | attr_writer :wrap 5 | 6 | def keys 7 | @keys ||= super 8 | end 9 | 10 | def wrap? 11 | @wrap 12 | end 13 | 14 | def each(&block) 15 | store if wrap? # TODO: why? This is preventing streaming 16 | @records&.each(&block) || super 17 | end 18 | 19 | ## To avoid to_a on Neo4j::Driver::Result as that one does not call the above block 20 | def to_a 21 | map.to_a 22 | end 23 | 24 | def store 25 | return if @records 26 | keys 27 | @records = [] 28 | # TODO: implement 'each' without block parameter 29 | method(:each).super_method.call do |record| 30 | record.wrap = wrap? 31 | @records << record 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /e2e_tests/model_generator_spec.rb: -------------------------------------------------------------------------------- 1 | load 'myapp/app/models/user.rb' 2 | load_migration('create_user.rb') 3 | 4 | describe 'Model Generator' do 5 | describe 'generated model class' do 6 | it 'includes ActiveGraph::Node' do 7 | expect(User < ActiveGraph::Node).to be true 8 | end 9 | 10 | it 'declares correct property' do 11 | expect(User.attributes['name'].type).to be String 12 | end 13 | end 14 | 15 | describe 'generated migration file' do 16 | it 'inherits from ActiveGraph::Migrations::Base' do 17 | expect(CreateUser).to be < ActiveGraph::Migrations::Base 18 | end 19 | 20 | it 'can be run/rollback without issue' do 21 | migration = CreateUser.new(nil) 22 | migration.down rescue nil # we make sure migration is not run before 23 | expect do 24 | migration.up 25 | migration.down 26 | end.not_to raise_error 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /docs/activegraph.rb: -------------------------------------------------------------------------------- 1 | # Usage: rails new myapp -m activegraph.rb 2 | 3 | gem 'activegraph', '>= 11.1' 4 | 5 | gem_group :development do 6 | gem 'neo4j-rake_tasks' 7 | end 8 | 9 | inject_into_file 'config/application.rb', before: '# Require the gems listed in Gemfile' do < false 22 | end 23 | 24 | ] 25 | 26 | environment generator 27 | environment nil, env: 'development' do <`_ 7 | * `How NEO4J Saved my Relationship by Coraline Ada Ehmke `_ 8 | * `Why You Should Use Neo4j in Your Next Ruby App `_ 9 | * `Query or QueryProxy? `_ 10 | * `Getting Started with Neo4j and Ruby `_ 11 | * Example Sinatra applications 12 | 13 | - `Using the neo4j gem `_ 14 | - `Using only the neo4j-core gem `_ 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/active_graph/relationship/wrapping.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Relationship 3 | module Wrapping 4 | class << self 5 | def wrapper(rel) 6 | rel.properties.symbolize_keys! 7 | begin 8 | most_concrete_class = class_from_type(rel.type).constantize 9 | return rel unless most_concrete_class < ActiveGraph::Relationship 10 | most_concrete_class.new 11 | rescue NameError => e 12 | raise e unless e.message =~ /(uninitialized|wrong) constant/ 13 | 14 | return rel 15 | end.tap do |wrapped_rel| 16 | wrapped_rel.init_on_load(rel, rel.start_node_id, rel.end_node_id, rel.type) 17 | end 18 | end 19 | 20 | def class_from_type(type) 21 | ActiveGraph::Relationship::Types::WRAPPED_CLASSES[type] || ActiveGraph::Relationship::Types::WRAPPED_CLASSES[type] = type.to_s.downcase.camelize 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /docs/_yard/custom_templates/default/fulldoc/setup.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | def init 4 | root_object = @objects.find(&:root?) 5 | 6 | root_object.children.each do |object| 7 | render_node(object) 8 | end 9 | 10 | @toc = root_object.children.map(&:name) 11 | 12 | asset 'index.rst', erb(:index) 13 | end 14 | 15 | def render_node(object, ancestry = []) 16 | path = ancestry.map(&:name).map(&:to_s).join('/') 17 | 18 | @module = object 19 | @path = path 20 | 21 | FileUtils.mkdir_p(path) unless path.empty? 22 | asset File.join(path, "#{object.name}.rst"), erb(:module) 23 | 24 | return if !object.respond_to?(:children) 25 | 26 | object.children.each do |child| 27 | render_node(child, ancestry + [object]) if child.is_a?(YARD::CodeObjects::NamespaceObject) 28 | end 29 | end 30 | 31 | def asset(path, content) 32 | return if !options.serializer 33 | 34 | log.capture("Generating asset #{path}") do 35 | options.serializer.serialize(path, content) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/upgrade_v8/upgrade_v8_generator.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../migration_helper' 2 | require_relative '../../source_path_helper' 3 | 4 | module ActiveGraph 5 | module Generators 6 | class UpgradeV8Generator < ::Rails::Generators::Base 7 | include ActiveGraph::Generators::SourcePathHelper 8 | include ActiveGraph::Generators::MigrationHelper 9 | 10 | def create_upgrade_v8_file 11 | @schema = load_all_models_schema! 12 | migration_template 'migration.erb' 13 | end 14 | 15 | def file_name 16 | 'upgrate_to_v8' 17 | end 18 | 19 | private 20 | 21 | def load_all_models_schema! 22 | Rails.application.eager_load! 23 | initialize_all_models! 24 | ActiveGraph::ModelSchema.legacy_model_schema_informations 25 | end 26 | 27 | def initialize_all_models! 28 | models = ActiveGraph::Node.loaded_classes 29 | models.map(&:ensure_id_property_info!) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/unit/relationship/rel_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Relationship::Wrapping do 2 | let(:id) { 1 } 3 | let(:type) { :DEFAULT } 4 | let(:properties) { {} } 5 | let(:start_node_id) { 1 } 6 | let(:end_node_id) { 2 } 7 | 8 | let(:rel) { double(start_node_id: start_node_id, end_node_id: end_node_id, type: type, properties: properties) } 9 | subject { ActiveGraph::Relationship::Wrapping.wrapper(rel) } 10 | 11 | it { should eq(rel) } 12 | 13 | context 'HasFoo Relationship class defined' do 14 | before do 15 | stub_relationship_class('HasFoo') do 16 | property :bar 17 | property :biz 18 | end 19 | end 20 | 21 | let_context type: :HAS_FOO do 22 | it { should be_a(HasFoo) } 23 | 24 | let_context(properties: { 'bar' => 'baz', 'biz' => 1 }) do 25 | its(:bar) { should eq('baz') } 26 | its(:biz) { should eq(1) } 27 | 28 | its('start_node.neo_id') { should eq(1) } 29 | its('end_node.neo_id') { should eq(2) } 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_graph/shared/cypher.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | module Cypher 3 | module CreateMethod 4 | def create_method 5 | creates_unique? ? :create_unique : :create 6 | end 7 | 8 | def creates_unique(option = :none) 9 | option = :none if option == true 10 | @creates_unique = option 11 | end 12 | 13 | def creates_unique_option 14 | @creates_unique || :none 15 | end 16 | 17 | def creates_unique? 18 | !!@creates_unique 19 | end 20 | alias unique? creates_unique? 21 | end 22 | 23 | module RelIdentifiers 24 | extend ActiveSupport::Concern 25 | 26 | [:from_node, :to_node, :rel].each do |element| 27 | define_method("#{element}_identifier") do 28 | instance_variable_get(:"@#{element}_identifier") || element 29 | end 30 | 31 | define_method("#{element}_identifier=") do |id| 32 | instance_variable_set(:"@#{element}_identifier", id.to_sym) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/active_graph/core/cypher_error_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | describe CypherError do 4 | let(:code) { 'SomeError' } 5 | let(:message) { 'some fancy error' } 6 | let(:stack_trace) { "class1:1\nclass2:2\nclass3:3" } 7 | subject { described_class.new_from(code, message, stack_trace) } 8 | 9 | its(:class) { is_expected.to eq(described_class) } 10 | its(:inspect) { is_expected.to include(message) } 11 | its(:message) { is_expected.to include(message, code, stack_trace) } 12 | 13 | its(:original_message) { is_expected.to eq(message) } 14 | its(:code) { is_expected.to eq(code) } 15 | its(:stack_trace) { is_expected.to eq(stack_trace) } 16 | 17 | let_context code: 'ConstraintValidationFailed' do 18 | it { is_expected.to be_a(SchemaErrors::ConstraintValidationFailedError) } 19 | end 20 | 21 | let_context code: 'ConstraintViolation' do 22 | it { is_expected.to be_a(SchemaErrors::ConstraintValidationFailedError) } 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/active_graph/paginated.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | class Paginated 3 | include Enumerable 4 | attr_reader :items, :total, :current_page 5 | 6 | def initialize(items, total, current_page) 7 | @items = items 8 | @total = total 9 | @current_page = current_page 10 | end 11 | 12 | def self.create_from(source, page, per_page, order = nil) 13 | target = source.node_var || source.identity 14 | partial = source.skip((page - 1) * per_page).limit(per_page) 15 | ordered_partial, ordered_source = if order 16 | [partial.order_by(order), source.query.with("#{target} as #{target}").pluck("COUNT(#{target})").first] 17 | else 18 | [partial, source.count] 19 | end 20 | Paginated.new(ordered_partial, ordered_source, page) 21 | end 22 | 23 | delegate :each, to: :items 24 | delegate :pluck, to: :items 25 | delegate :size, :[], to: :items 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/shared_examples/schema/schema_operation_interface_unit.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'schema operation interface' do |instance| 2 | describe 'public interface' do 3 | subject { instance } 4 | it { is_expected.to respond_to(:create!) } 5 | it { is_expected.to respond_to(:label_object) } 6 | it { is_expected.to respond_to(:drop!) } 7 | it { is_expected.to respond_to(:drop_incompatible!) } 8 | it { is_expected.to respond_to(:exist?) } 9 | it { is_expected.to respond_to(:default_options) } 10 | it { is_expected.to respond_to(:type) } 11 | it { is_expected.to respond_to(:incompatible_operation_classes) } 12 | end 13 | 14 | describe '#drop_incompatible!' do 15 | describe 'drop_incompatible!' do 16 | it 'checks presence, drops when found' do 17 | instance.class.incompatible_operation_classes.each do |c| 18 | expect_any_instance_of(c).to receive(:exist?).and_return true 19 | expect_any_instance_of(c).to receive(:drop!) 20 | end 21 | 22 | instance.drop_incompatible! 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/unit/migrations/check_pending_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Migrations::CheckPending do 2 | let(:with_migrations!) do 3 | allow(ActiveGraph::Migrations::Runner).to receive(:files_path) do 4 | Rails.root.join('spec', 'migration_files', 'migrations', '*.rb') 5 | end 6 | end 7 | subject do 8 | app = double(call: true) 9 | described_class.new(app) 10 | end 11 | 12 | it 'does nothing when there are no migrations' do 13 | expect(ActiveGraph::Migrations).not_to receive(:check_for_pending_migrations!) 14 | subject.call({}) 15 | end 16 | 17 | context 'when there are some migrations' do 18 | before { with_migrations! } 19 | it 'checks for pending' do 20 | expect(ActiveGraph::Migrations).to receive(:check_for_pending_migrations!) 21 | subject.call({}) 22 | end 23 | 24 | it 'doesn\'t check @last_check is up to date' do 25 | subject.instance_variable_set(:@last_check, 9_999_999_999) 26 | expect(ActiveGraph::Migrations).not_to receive(:check_for_pending_migrations!) 27 | subject.call({}) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {%- block extrahead %} 4 | 9 | 10 | 23 | 24 | 34 | 35 | {% endblock %} 36 | 37 | -------------------------------------------------------------------------------- /lib/active_graph/shared/serialized_properties.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | # This module adds the `serialize` class method. It lets you store hashes and arrays in Neo4j properties. 3 | # Be aware that you won't be able to search within serialized properties and stuff use indexes. If you do a regex search for portion of a string 4 | # property, the search happens in Cypher and you may take a performance hit. 5 | # 6 | # See type_converters.rb for the serialization process. 7 | module SerializedProperties 8 | extend ActiveSupport::Concern 9 | 10 | def serialized_properties 11 | self.class.serialized_properties 12 | end 13 | 14 | def serializable_hash(*args) 15 | super.merge(id: id) 16 | end 17 | 18 | 19 | module ClassMethods 20 | def inherited(other) 21 | inherit_serialized_properties(other) if self.respond_to?(:serialized_properties) 22 | super 23 | end 24 | 25 | def inherit_serialized_properties(other) 26 | other.serialized_properties = self.serialized_properties 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Andreas Ronge 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /spec/active_graph/core/query_parameters_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Core::Query::Parameters do 2 | let(:parameters) { ActiveGraph::Core::Query::Parameters.new } 3 | 4 | it 'lets you add params' do 5 | expect(parameters.add_param(:foo, 1)).to eq(:foo) 6 | 7 | expect(parameters.to_hash).to eq(foo: 1) 8 | end 9 | 10 | it 'lets you add a second param' do 11 | expect(parameters.add_param(:foo, 1)).to eq(:foo) 12 | expect(parameters.add_param(:bar, 'baz')).to eq(:bar) 13 | 14 | expect(parameters.to_hash).to eq(foo: 1, bar: 'baz') 15 | end 16 | 17 | it 'does not let the same parameter be used twice' do 18 | expect(parameters.add_param(:foo, 1)).to eq(:foo) 19 | expect(parameters.add_param(:foo, 2)).to eq(:foo2) 20 | 21 | expect(parameters.to_hash).to eq(foo: 1, foo2: 2) 22 | end 23 | 24 | it 'allows you to add multiple params at the same time' do 25 | expect(parameters.add_params(foo: 1)).to eq([:foo]) 26 | expect(parameters.add_params(foo: 2, bar: 'baz')).to eq(%i[foo2 bar]) 27 | 28 | expect(parameters.to_hash).to eq(foo: 1, foo2: 2, bar: 'baz') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/active_graph/lazy_attribute_hash.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | class LazyAttributeHash < ActiveModel::LazyAttributeHash 3 | def initialize(values, attr_list) 4 | @types = {} 5 | @values = {} 6 | @additional_types = {} 7 | @materialized = false 8 | @delegate_hash = values 9 | 10 | @default_attributes = process_default_attributes(attr_list) 11 | end 12 | 13 | private 14 | 15 | def marshal_load(values) 16 | initialize(values[4], values[3]) 17 | end 18 | 19 | def process_default_attributes(attr_list) 20 | if attr_list.is_a?(Hash) 21 | attr_list 22 | else 23 | # initialize default attributes map with nil values 24 | attr_list.each_with_object({}) do |name, map| 25 | map[name] = nil 26 | end 27 | end 28 | end 29 | 30 | # we are using with_cast_value here because at the moment casting is being managed by 31 | # Neo4j and not in ActiveModel 32 | def assign_default_value(name) 33 | delegate_hash[name] = ActiveModel::Attribute.with_cast_value(name, default_attributes[name].dup, nil) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/e2e/railtie_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_graph/railtie' 2 | 3 | module Rails 4 | describe 'railtie' do 5 | after(:context) { set_default_driver } 6 | 7 | describe '#setup!' do 8 | let(:driver_path) {} 9 | let(:cfg) do 10 | ActiveGraph::Railtie.empty_config.dup.tap do |c| 11 | c.driver.path = driver_path if driver_path 12 | c.driver.abc = 1 13 | end 14 | end 15 | 16 | let(:raise_expectation) { [:not_to, raise_error] } 17 | 18 | context 'no errors' do 19 | before do 20 | stub_const('Neo4j::Driver::GraphDatabase', spy('Neo4j::Driver::GraphDatabase')) 21 | 22 | expect do 23 | ActiveGraph::Railtie.setup!(cfg) 24 | end.send(*raise_expectation) 25 | end 26 | 27 | context 'NEO4J_URL is bolt' do 28 | let_env_variable(:NEO4J_URL) { 'bolt://localhost:7472' } 29 | 30 | it 'calls ActiveGraph::Base' do 31 | expect(Neo4j::Driver::GraphDatabase).to have_received(:driver).with('bolt://localhost:7472', Object, abc: 1) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/shared_examples/scopable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'scopable model' do 2 | describe 'Person.top_students.to_a' do 3 | subject do 4 | Person.top_students.to_a 5 | end 6 | it { is_expected.to match_array([@a, @b, @b1, @b2]) } 7 | end 8 | 9 | describe 'person.friends.top_students.to_a' do 10 | subject do 11 | @a.friends.top_students.to_a 12 | end 13 | it { is_expected.to match_array([@b]) } 14 | end 15 | 16 | describe 'person.friends.friend.top_students.to_a' do 17 | subject do 18 | @a.friends.friends.top_students.to_a 19 | end 20 | it { is_expected.to match_array([@b1, @b2]) } 21 | end 22 | 23 | describe 'person.top_students.friends.to_a' do 24 | subject do 25 | @a.friends.top_students.friends.to_a 26 | end 27 | it { is_expected.to match_array([@b1, @b2]) } 28 | end 29 | end 30 | 31 | 32 | shared_examples_for 'chained scopable model' do 33 | describe 'Person.top_students.top_students.to_a' do 34 | subject do 35 | Person.top_students.friends.top_students.to_a 36 | end 37 | it { is_expected.to match_array([@b, @b1, @b2]) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | The Neo4j.rb docs! 2 | 3 | This directory is a sphinx project designed to work with https://readthedocs.org/ 4 | 5 | There is a set of static *.rst files which provide tutorial-style overview documentation for the Neo4j.rb project. In addition there are [YARD](http://yardoc.org/) templates which output the API documentation from the comments in the Ruby source code to *.rst files under the `api` directory. 6 | 7 | # Prerequisites 8 | 9 | `pip3 install -U Sphinx` 10 | 11 | `pip3 install sphinx_rtd_theme` 12 | 13 | # Rake tasks 14 | 15 | ## `rake docs:yard` 16 | 17 | Run YARD to build the `api` docs directory. This wipes and rebuilds the `docs/_build/_yard` directory. The YARD templates which output RST files are under `docs/_yard/custom_templates` 18 | 19 | ## `rake docs:sphinx` 20 | 21 | Builds the sphinx RST files into HTML documentation. This includes wiping and rebuilding the `docs/api` directory from `docs/_build/_yard`. The output directory is `docs/_build/html` 22 | 23 | ## `rake docs` 24 | 25 | Run `docs:yard` and then `docs:sphinx` 26 | 27 | ## `rake docs:open` 28 | 29 | Shortcut to open `docs/_build/html/index.html` in your browser 30 | 31 | -------------------------------------------------------------------------------- /spec/e2e/active_graph-core_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'works well together with ActiveGraph::Core' do 2 | before do 3 | clear_model_memory_caches 4 | 5 | stub_node_class('Clazz') do 6 | has_many :out, :stuff, type: :stuff, model_class: false 7 | end 8 | end 9 | 10 | it 'can add ActiveGraph::Core::Node to declared relationships' do 11 | obj = Clazz.create 12 | wrapped_node = Clazz.create 13 | node = wrapped_node._persisted_obj 14 | obj.stuff << node 15 | result = obj.query_as(:n).match('(n)-[:stuff]->(m)').pluck(:m) 16 | expect(result).to eq([wrapped_node]) 17 | 18 | result = obj.stuff.to_a 19 | expect(result).to eq([wrapped_node]) 20 | end 21 | 22 | # I don't think that this should work this way 23 | # Associations should always return the wrapped objects 24 | # Maybe we could support that via a method call, but it's easy enough to 25 | # map(&:_persisted_obj) 26 | # 27 | # it 'can retrieve ActiveGraph::Core::Node from declared relationships' do 28 | # obj = Clazz.create 29 | # node = Clazz.create._persisted_obj 30 | # obj.stuff << node 31 | # expect(obj.stuff.to_a).to eq([node]) 32 | # end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_graph/core/cypher_error.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | class CypherError < StandardError 4 | attr_reader :code, :original_message, :stack_trace 5 | 6 | def initialize(code = nil, original_message = nil, stack_trace = nil) 7 | @code = code 8 | @original_message = original_message 9 | @stack_trace = stack_trace 10 | 11 | msg = <<-ERROR 12 | Cypher error: 13 | #{ANSI::CYAN}#{code}#{ANSI::CLEAR}: #{original_message} 14 | #{stack_trace} 15 | ERROR 16 | super(msg) 17 | end 18 | 19 | def self.new_from(code, message, stack_trace = nil) 20 | error_class_from(code).new(code, message, stack_trace) 21 | end 22 | 23 | def self.error_class_from(code) 24 | case code 25 | when /(ConstraintValidationFailed|ConstraintViolation)/ 26 | SchemaErrors::ConstraintValidationFailedError 27 | when /IndexAlreadyExists/ 28 | SchemaErrors::IndexAlreadyExistsError 29 | when /ConstraintAlreadyExists/ # ????? 30 | SchemaErrors::ConstraintAlreadyExistsError 31 | else 32 | CypherError 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/unit/shared/query_factory_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | describe QueryFactory do 4 | before do 5 | stub_node_class('NodeClass') {} 6 | 7 | stub_relationship_class('RelClass') do 8 | from_class false 9 | to_class false 10 | end 11 | end 12 | 13 | describe '.factory_for' do 14 | subject { described_class.factory_for(graph_obj) } 15 | 16 | context 'with Relationship' do 17 | let(:graph_obj) { RelClass.new } 18 | it { is_expected.to eq RelQueryFactory } 19 | end 20 | 21 | context 'with Node' do 22 | let(:graph_obj) { NodeClass.new } 23 | it { is_expected.to eq NodeQueryFactory } 24 | end 25 | 26 | context 'with RelatedNode' do 27 | let(:graph_obj) { ActiveGraph::Relationship::RelatedNode.new(NodeClass.new) } 28 | it { is_expected.to eq NodeQueryFactory } 29 | end 30 | 31 | context 'with anything else' do 32 | let(:graph_obj) { 'foo' } 33 | it { expect { subject }.to raise_error RuntimeError, /Unable to find factory for/ } 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/active_graph/core/wrappable_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Core::Wrappable do 2 | before do 3 | stub_const 'WrapperClass', (Class.new do 4 | attr_reader :wrapped_object 5 | 6 | def initialize(obj) 7 | @wrapped_object = obj 8 | end 9 | end) 10 | 11 | stub_const 'WrappableClass', (Class.new do 12 | include ActiveGraph::Core::Wrappable 13 | end) 14 | end 15 | 16 | describe '.wrapper_callback' do 17 | it 'does not allow for two callbacks' do 18 | WrappableClass.wrapper_callback(&WrapperClass.method(:new)) 19 | 20 | expect do 21 | WrappableClass.wrapper_callback {} 22 | end.to raise_error(/Callback already specified!/) 23 | end 24 | 25 | it 'returns the wrappable object if no callback is specified' do 26 | obj = WrappableClass.new 27 | expect(obj.wrap).to eq(obj) 28 | end 29 | 30 | it 'allow users to specify a callback which will create a wrapper object' do 31 | WrappableClass.wrapper_callback(&WrapperClass.method(:new)) 32 | 33 | obj = WrappableClass.new 34 | wrapper_obj = obj.wrap 35 | expect(wrapper_obj.wrapped_object).to eq(obj) 36 | end 37 | end 38 | 39 | # Should pass on method calls? 40 | end 41 | -------------------------------------------------------------------------------- /docs/QueryClauseMethods.rst.base: -------------------------------------------------------------------------------- 1 | QueryClauseMethods 2 | ================== 3 | 4 | The ``ActiveGraph::Core::Query`` class from the `neo4j-core` gem defines a DSL which allows for easy creation of Neo4j `Cypher queries `_. They can be started from a session like so: 5 | 6 | .. code-block:: ruby 7 | 8 | a_session.query 9 | # The current session for `Node` / `Relationship` in the `neo4j` gem can be retrieved with `ActiveGraph::Base.current_session` 10 | 11 | Advantages of using the `Query` class include: 12 | 13 | * Method chaining allows you to build a part of a query and then pass it somewhere else to be built further 14 | * Automatic use of parameters when possible 15 | * Ability to pass in data directly from other sources (like Hash to match keys/values) 16 | * Ability to use native Ruby objects (such as translating `nil` values to `IS NULL`, regular expressions to Cypher-style regular expression matches, etc...) 17 | 18 | Below is a series of Ruby code samples and the resulting Cypher that would be generated. These examples are all generated directly from the `spec file `_ and are thus all tested to work. 19 | 20 | -------------------------------------------------------------------------------- /lib/active_graph/core/querable.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module Querable 4 | extend ActiveSupport::Concern 5 | include Instrumentable 6 | 7 | class_methods do 8 | def query(*args) 9 | options = case args.size 10 | when 3 11 | args.pop 12 | when 2 13 | args.pop if args[0].is_a?(::ActiveGraph::Core::Query) 14 | end || {} 15 | 16 | query_run(QueryBuilder.query(*args), options) 17 | end 18 | 19 | def setup_query!(query, options = {}) 20 | return if options[:skip_instrumentation] 21 | ActiveSupport::Notifications.instrument('neo4j.core.cypher_query', query: query) 22 | end 23 | 24 | def query_run(query, options = {}) 25 | setup_query!(query, skip_instrumentation: options[:skip_instrumentation]) 26 | 27 | ActiveSupport::Notifications.instrument('neo4j.core.bolt.request') do 28 | transaction do |tx| 29 | tx.run(query.cypher, **query.parameters).tap { |result| result.wrap = options[:wrap] != false } 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/active_graph/node/labels/index.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node::Labels 2 | module Index 3 | extend ActiveSupport::Concern 4 | 5 | module ClassMethods 6 | extend Forwardable 7 | 8 | def_delegators :declared_properties, :indexed_properties 9 | 10 | # Creates a Neo4j index on given property 11 | # 12 | # This can also be done on the property directly, see ActiveGraph::Node::Property::ClassMethods#property. 13 | # 14 | # @param [Symbol] property the property we want a Neo4j index on 15 | # 16 | # @example 17 | # class Person 18 | # include ActiveGraph::Node 19 | # property :name 20 | # index :name 21 | # end 22 | def index(property) 23 | return if ActiveGraph::ModelSchema.defined_constraint?(self, property) 24 | 25 | ActiveGraph::ModelSchema.add_defined_index(self, property) 26 | end 27 | 28 | # Creates a neo4j constraint on this class for given property 29 | # 30 | # @example 31 | # Person.constraint :name, type: :unique 32 | def constraint(property, _constraints = {type: :unique}) 33 | ActiveGraph::ModelSchema.add_defined_constraint(self, property) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/e2e/typecasting_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'custom type conversion' do 2 | before(:each) do 3 | clear_model_memory_caches 4 | 5 | stub_named_class('RangeConverter') do 6 | class << self 7 | def primitive_type 8 | String 9 | end 10 | 11 | def convert_type 12 | Range 13 | end 14 | 15 | def to_db(value) 16 | value.to_s 17 | end 18 | 19 | def to_ruby(value) 20 | ends = value.to_s.split('..').map { |d| Integer(d) } 21 | ends[0]..ends[1] 22 | end 23 | alias_method :call, :to_ruby 24 | end 25 | 26 | include ActiveGraph::Shared::Typecaster 27 | end 28 | 29 | stub_node_class('RangeConvertPerson') do 30 | property :my_range, type: Range 31 | end 32 | end 33 | 34 | it 'registers' do 35 | expect(ActiveGraph::Shared::TypeConverters::CONVERTERS).to have_key(Range) 36 | end 37 | 38 | before { RangeConvertPerson.create!(my_range: 1..30) } 39 | let(:r) { RangeConvertPerson.first } 40 | 41 | it 'uses for persistence' do 42 | expect(r.my_range).to be_a(Range) 43 | end 44 | 45 | it 'uses for QueryProxy #where' do 46 | expect(RangeConvertPerson.where(my_range: 1..30).first).to eq r 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_graph/relationship/initialize.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Relationship 2 | module Initialize 3 | extend ActiveSupport::Concern 4 | include ActiveGraph::Shared::Initialize 5 | 6 | # called when loading the rel from the database 7 | # @param [ActiveGraph::Embedded::EmbeddedRelationship, Neo4j::Server::CypherRelationship] persisted_rel properties of this relationship 8 | # @param [ActiveGraph::Relationship] from_node_id The neo_id of the starting node of this rel 9 | # @param [ActiveGraph::Relationship] to_node_id The neo_id of the ending node of this rel 10 | # @param [String] type the relationship type 11 | def init_on_load(persisted_rel, from_node_id, to_node_id, type) 12 | @type = type 13 | @_persisted_obj = persisted_rel 14 | changed_attributes_clear! 15 | @attributes = convert_and_assign_attributes(persisted_rel.properties) 16 | load_nodes(from_node_id, to_node_id) 17 | end 18 | 19 | def init_on_reload(unwrapped_reloaded) 20 | @attributes = nil 21 | init_on_load(unwrapped_reloaded, 22 | unwrapped_reloaded.start_node_id, 23 | unwrapped_reloaded.end_node_id, 24 | unwrapped_reloaded.type) 25 | self 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/shared/mass_assignment_spec.rb: -------------------------------------------------------------------------------- 1 | # Originally part of ActiveAttr, https://github.com/cgriego/active_attr 2 | module ActiveGraph::Shared 3 | describe MassAssignment, :mass_assignment do 4 | before do 5 | model_class.class_eval do 6 | include MassAssignment 7 | end 8 | end 9 | 10 | shared_examples 'lenient mass assignment method', lenient_mass_assignment_method: true do 11 | include_examples 'mass assignment method' 12 | 13 | it 'ignores attributes which do not have a writer' do 14 | person = mass_assign_attributes(middle_initial: 'J') 15 | person.instance_eval { @middle_initial ||= nil } 16 | expect(person.instance_variable_get('@middle_initial')).to be_nil 17 | expect(person).not_to respond_to :middle_initial 18 | end 19 | 20 | it 'ignores trying to assign a private attribute' do 21 | person = mass_assign_attributes(middle_name: 'J') 22 | expect(person.middle_name).to be_nil 23 | end 24 | end 25 | 26 | describe '#assign_attributes', :assign_attributes, :lenient_mass_assignment_method 27 | describe '#attributes=', :attributes=, :lenient_mass_assignment_method 28 | describe '#initialize', :initialize, :lenient_mass_assignment_method 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/e2e/model_schema_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::ModelSchema do 2 | before { delete_schema } 3 | 4 | before do 5 | create_index :User, :name, type: :exact 6 | 7 | stub_node_class('User') do 8 | property :username, constraint: :unique 9 | property :name, index: :exact 10 | enum role: [:none, :staff, :admin] 11 | end 12 | 13 | stub_node_class('Book') do 14 | id_property :isbn 15 | end 16 | end 17 | 18 | let(:schema) { described_class.legacy_model_schema_informations } 19 | 20 | it 'lists every legacy schema information' do 21 | Book.ensure_id_property_info! 22 | expect(schema[:constraint]).to match_array([ 23 | {label: :Book, model: Book, property_name: :isbn}, 24 | {label: :User, model: User, property_name: :uuid}, 25 | {label: :User, model: User, property_name: :username} 26 | ]) 27 | 28 | expect(schema[:index]).to match_array([ 29 | {label: :User, model: User, property_name: :name}, 30 | {label: :User, model: User, property_name: :role} 31 | ]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_graph/core/query_ext.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module QueryExt 4 | # Creates a ActiveGraph::Node::Query::QueryProxy object that builds off of a Core::Query object. 5 | # 6 | # @param [Class] model An Node model to be used as the start of a new QueryuProxy chain 7 | # @param [Symbol] var The variable to be used to refer to the object from within the new QueryProxy 8 | # @param [Boolean] optional Indicate whether the new QueryProxy will use MATCH or OPTIONAL MATCH. 9 | # @return [ActiveGraph::Node::Query::QueryProxy] A QueryProxy object. 10 | def proxy_as(model, var, optional = false) 11 | # TODO: Discuss whether it's necessary to call `break` on the query or if this should be left to the user. 12 | ActiveGraph::Node::Query::QueryProxy.new(model, nil, node: var, optional: optional, starting_query: self, chain_level: @proxy_chain_level) 13 | end 14 | 15 | # Calls proxy_as with `optional` set true. This doesn't offer anything different from calling `proxy_as` directly but it may be more readable. 16 | def proxy_as_optional(model, var) 17 | proxy_as(model, var, true) 18 | end 19 | 20 | # For instances where you turn a QueryProxy into a Query and then back to a QueryProxy with `#proxy_as` 21 | attr_accessor :proxy_chain_level 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /docs/HelperGems.rst: -------------------------------------------------------------------------------- 1 | Helper Gems 2 | ==================== 3 | 4 | devise-activegraph 5 | ------------ 6 | 7 | `devise-activegraph `_ is an adaptor gem for using the devise authentication library with ActiveGraph. 8 | 9 | cancancan-activegraph 10 | -------------------- 11 | 12 | The `cancancan-neo4j gem `_ is the neo4j adapter for the `CanCanCan `_ authorisation library. This gem will help you seamlessly integrate cancan gem to your Ruby/Rails app wich has Neo4j as database. 13 | 14 | neo4j-paperclip 15 | --------------- 16 | 17 | Currently not compatible with ``activegraph`` 18 | The `neo4jrb-paperclip `_ gem allows easy use of the ``paperclip`` gem in ``Node`` and ``Relationship`` models. 19 | 20 | neo4jrb_spatial 21 | --------------- 22 | 23 | Obsolete due to native neo4j data types. 24 | The `neo4jrb_spatial `_ gem add the ability to work with the Neo4j Spatial server plugin via the ``neo4j`` and ``neo4j-core`` gems 25 | 26 | neo4j-rspec 27 | ----------- 28 | 29 | Currently not compatible with ``activegraph`` 30 | The `neo4j-rspec `_ gem adds RSpec matchers for easier testing of ``Node`` and ``Relationship`` models. 31 | -------------------------------------------------------------------------------- /lib/active_graph/shared.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | extend ActiveSupport::Concern 4 | extend ActiveModel::Naming 5 | 6 | include ActiveModel::Conversion 7 | begin 8 | include ActiveModel::Serializers::Xml 9 | rescue NameError; end # rubocop:disable Lint/HandleExceptions 10 | include ActiveModel::Serializers::JSON 11 | 12 | module ClassMethods 13 | # This should be used everywhere. Should make it easy 14 | # to support a driver-per-model system 15 | def neo4j_query(*args) 16 | ActiveGraph::Base.query(*args) 17 | end 18 | 19 | def new_query 20 | ActiveGraph::Base.new_query 21 | end 22 | end 23 | 24 | included do 25 | self.include_root_in_json = ActiveGraph::Config.include_root_in_json 26 | @_declared_properties ||= ActiveGraph::Shared::DeclaredProperties.new(self) 27 | 28 | def self.i18n_scope 29 | :neo4j 30 | end 31 | 32 | def self.inherited(other) 33 | attributes.each_pair do |k, v| 34 | other.inherit_property k.to_sym, v.clone, declared_properties[k].options 35 | end 36 | super 37 | end 38 | end 39 | 40 | def declared_properties 41 | self.class.declared_properties 42 | end 43 | 44 | def neo4j_query(*args) 45 | self.class.neo4j_query(*args) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/unit/shared/rel_type_converters_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'rel type conversion' do 2 | let(:clazz) do 3 | Class.new do 4 | include ActiveGraph::Shared::RelTypeConverters 5 | end 6 | end 7 | 8 | describe 'ActiveGraph::Config[:transform_rel_type]' do 9 | context 'with upcase' do 10 | before(:each) do 11 | ActiveGraph::Shared::RelTypeConverters.instance_variable_set(:@decorated_rel_type, nil) 12 | ActiveGraph::Shared::RelTypeConverters.instance_variable_set(:@rel_transformer, nil) 13 | end 14 | 15 | after(:all) { ActiveGraph::Config[:transform_rel_type] = :downcase } 16 | 17 | it 'upcases' do 18 | ActiveGraph::Config[:transform_rel_type] = :upcase 19 | expect(clazz.new.decorated_rel_type('RelType')).to eq 'REL_TYPE' 20 | end 21 | 22 | it 'downcases' do 23 | ActiveGraph::Config[:transform_rel_type] = :downcase 24 | expect(clazz.new.decorated_rel_type('RelType')).to eq 'rel_type' 25 | end 26 | 27 | it 'uses legacy' do 28 | ActiveGraph::Config[:transform_rel_type] = :legacy 29 | expect(clazz.new.decorated_rel_type('RelType')).to eq '#rel_type' 30 | end 31 | 32 | it 'uses none' do 33 | ActiveGraph::Config[:transform_rel_type] = :none 34 | expect(clazz.new.decorated_rel_type('RelType')).to eq 'RelType' 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/active_graph/node/dependent_callbacks.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module DependentCallbacks 4 | extend ActiveSupport::Concern 5 | 6 | def dependent_delete_callback(association, ids) 7 | association_query_proxy(association.name).where(id: ids).delete_all 8 | end 9 | 10 | def dependent_delete_orphans_callback(association, ids) 11 | unique_query = as(:self).unique_nodes(association, :self, :n, :other_rel, ids) 12 | unique_query.query.optional_match('(n)-[r]-()').delete(:n, :r).exec if unique_query 13 | end 14 | 15 | def dependent_destroy_callback(association, ids) 16 | unique_query = association_query_proxy(association.name).where(id: ids) 17 | unique_query.each_for_destruction(self, &:destroy) if unique_query 18 | end 19 | 20 | def dependent_destroy_orphans_callback(association, ids) 21 | unique_query = as(:self).unique_nodes(association, :self, :n, :other_rel, ids) 22 | unique_query.each_for_destruction(self, &:destroy) if unique_query 23 | end 24 | 25 | def callbacks_from_relationship(relationship, direction, other_node) 26 | rel = relationship_corresponding_rel(relationship, direction, other_node.class).try(:last) 27 | public_send("dependent_#{rel.dependent}_callback", rel, [other_node.id]) if rel && rel.dependent 28 | end 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /spec/unit/schema/operation_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Schema 2 | describe 'Operation classes' do 3 | let(:label_double) { double('An instance of ActiveGraph::Core::Label') } 4 | before { allow(ActiveGraph::Core::Label).to receive(:new).and_return(label_double) } 5 | 6 | describe 'public interface' do 7 | [Operation, ExactIndexOperation, UniqueConstraintOperation].each do |c| 8 | instance = c.new('label', 'property') 9 | it_behaves_like 'schema operation interface', instance 10 | end 11 | end 12 | 13 | describe 'methods provided by base' do 14 | let(:instance) { Operation.new('label', 'property') } 15 | 16 | describe '#create!' do 17 | it 'drops incompatible, creates' do 18 | expect(instance).to receive(:drop_incompatible!) 19 | expect(instance).to receive(:exist?) 20 | expect(instance).to receive(:type).and_return('foo') 21 | expect(label_double).to receive(:send).with(:create_foo, :property, {}) 22 | instance.create! 23 | end 24 | end 25 | 26 | describe '#drop!' do 27 | it 'sends the appropriate drop message to the label object based on its type' do 28 | expect(instance).to receive(:type).and_return('foo') 29 | expect(label_double).to receive(:send).with(:drop_foo, :property, {}) 30 | instance.drop! 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/active_graph/shared/declared_property/index.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | class DeclaredProperty 3 | # None of these methods interact with the database. They only keep track of property settings in models. 4 | # It could (should?) handle the actual indexing/constraining, but that's TBD. 5 | module Index 6 | def index_or_constraint? 7 | index?(:exact) || constraint?(:unique) 8 | end 9 | 10 | def index?(type = :exact) 11 | options.key?(:index) && options[:index] == type 12 | end 13 | 14 | def constraint?(type = :unique) 15 | options.key?(:constraint) && options[:constraint] == type 16 | end 17 | 18 | def index!(type = :exact) 19 | fail ActiveGraph::InvalidPropertyOptionsError, "Can't set index on constrainted property #{name} (constraints get indexes automatically)" if constraint?(:unique) 20 | options[:index] = type 21 | end 22 | 23 | def constraint!(type = :unique) 24 | fail ActiveGraph::InvalidPropertyOptionsError, "Can't set constraint on indexed property #{name} (constraints get indexes automatically)" if index?(:exact) 25 | options[:constraint] = type 26 | end 27 | 28 | def unindex!(type = :exact) 29 | options.delete(:index) if index?(type) 30 | end 31 | 32 | def unconstraint!(type = :unique) 33 | options.delete(:constraint) if constraint?(type) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/integration/orm_adapter/neo4j_spec.rb: -------------------------------------------------------------------------------- 1 | orm_adapter_path = `bundle show orm_adapter`.chomp 2 | require File.join(orm_adapter_path, 'spec/orm_adapter/example_app_shared') 3 | 4 | module ActiveGraph 5 | module OrmSpec 6 | describe '[Neo4j orm adapter]', type: :integration do 7 | # describe "the OrmAdapter class" do 8 | # subject { ActiveGraph::Node::OrmAdapter } 9 | # 10 | # specify "#model_classes should return all of the model classes (that are not in except_classes)" do 11 | # subject.model_classes.should include(User, Note) 12 | # end 13 | # end 14 | 15 | it_should_behave_like 'example app with orm_adapter fix' do 16 | before(:each) do 17 | clear_model_memory_caches 18 | 19 | create_index :User, :name, type: :exact 20 | create_index :User, :rating, type: :exact 21 | 22 | stub_node_class('User') do 23 | property :name 24 | property :rating, type: Integer 25 | 26 | has_many :out, :notes, type: nil, model_class: 'Note' 27 | end 28 | 29 | create_index :Note, :body, type: :exact 30 | stub_node_class('Note') do 31 | property :body 32 | 33 | has_one :in, :owner, type: :notes, model_class: 'User' 34 | end 35 | end 36 | 37 | let(:user_class) { User } 38 | let(:note_class) { Note } 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/active_graph/core/instrumentable.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module Instrumentable 4 | extend ActiveSupport::Concern 5 | 6 | EMPTY = '' 7 | NEWLINE_W_SPACES = "\n " 8 | 9 | class_methods do 10 | def subscribe_to_request 11 | ActiveSupport::Notifications.subscribe('neo4j.core.bolt.request') do |_, start, finish, _id, _payload| 12 | ms = (finish - start) * 1000 13 | yield " #{ANSI::BLUE}BOLT:#{ANSI::CLEAR} #{ANSI::YELLOW}#{ms.round}ms#{ANSI::CLEAR}" 14 | end 15 | end 16 | 17 | def subscribe_to_query 18 | ActiveSupport::Notifications.subscribe('neo4j.core.cypher_query') do |_, _start, _finish, _id, payload| 19 | query = payload[:query] 20 | params_string = (query.parameters && !query.parameters.empty? ? "| #{query.parameters.inspect}" : EMPTY) 21 | cypher = query.pretty_cypher ? (NEWLINE_W_SPACES if query.pretty_cypher.include?("\n")).to_s + query.pretty_cypher.gsub(/\n/, NEWLINE_W_SPACES) : query.cypher 22 | 23 | source_line, line_number = Logging.first_external_path_and_line(caller_locations) 24 | 25 | yield " #{ANSI::CYAN}#{query.context || 'CYPHER'}#{ANSI::CLEAR} #{cypher} #{params_string}" + 26 | ("\n ↳ #{source_line}:#{line_number}" if ActiveGraph::Config.fetch(:verbose_query_logs, false) && source_line).to_s 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/active_graph/class_arguments.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module ClassArguments 3 | class << self 4 | INVALID_CLASS_ARGUMENT_ERROR = 'option must be String, Symbol, false, nil, or an Array of Symbols/Strings' 5 | 6 | def valid_argument?(class_argument) 7 | [NilClass, String, Symbol, FalseClass].include?(class_argument.class) || 8 | (class_argument.is_a?(Array) && class_argument.all? { |c| [Symbol, String].include?(c.class) }) 9 | end 10 | 11 | def validate_argument!(class_argument, context) 12 | return if valid_argument?(class_argument) 13 | 14 | fail ArgumentError, "#{context} #{INVALID_CLASS_ARGUMENT_ERROR} (was #{class_argument.inspect})" 15 | end 16 | 17 | def node_model?(class_constant) 18 | class_constant.included_modules.include?(ActiveGraph::Node) 19 | end 20 | 21 | def constantize_argument(class_argument) 22 | case class_argument 23 | when 'any', :any, false, nil 24 | nil 25 | when Array 26 | class_argument.map(&method(:constantize_argument)) 27 | else 28 | class_argument.to_s.constantize.tap do |class_constant| 29 | if !node_model?(class_constant) 30 | fail ArgumentError, "#{class_constant} is not an Node model" 31 | end 32 | end 33 | end 34 | rescue NameError 35 | raise ArgumentError, "Could not find class: #{class_argument}" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/active_graph/undeclared_properties.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | # This mixin allows storage and update of undeclared properties in the included class 3 | module UndeclaredProperties 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | attr_accessor :undeclared_properties 8 | end 9 | 10 | def validate_attributes!(_) 11 | end 12 | 13 | def read_attribute(name) 14 | respond_to?(name) ? super(name) : read_undeclared_property(name.to_sym) 15 | end 16 | alias [] read_attribute 17 | 18 | def read_undeclared_property(name) 19 | _persisted_obj ? _persisted_obj.properties[name] : (undeclared_properties && undeclared_properties[name]) 20 | end 21 | 22 | def write_attribute(name, value) 23 | if respond_to? "#{name}=" 24 | super(name, value) 25 | else 26 | add_undeclared_property(name, value) 27 | end 28 | end 29 | alias []= write_attribute 30 | 31 | def skip_update? 32 | super && undeclared_properties.blank? 33 | end 34 | 35 | def props_for_create 36 | super.merge(undeclared_properties!) 37 | end 38 | 39 | def props_for_update 40 | super.merge(undeclared_properties!) 41 | end 42 | 43 | def undeclared_properties! 44 | undeclared_properties || {} 45 | ensure 46 | self.undeclared_properties = nil 47 | end 48 | 49 | def add_undeclared_property(name, value) 50 | (self.undeclared_properties ||= {})[name] = value 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/shared_examples/saveable_model.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'saveable model' do 2 | context 'when attempting to save' do 3 | it 'should save ok' do 4 | expect(subject.save).to be true 5 | end 6 | 7 | it 'should save without raising an exception' do 8 | expect { subject.save! }.to_not raise_error 9 | end 10 | 11 | context 'after save' do 12 | before(:each) { subject.save } 13 | 14 | it { is_expected.to be_valid } 15 | 16 | it { is_expected.to eq(subject.class.find(subject.id.to_s)) } 17 | 18 | it 'should be included in all' do 19 | expect(subject.class.all.to_a).to include(subject) 20 | end 21 | end 22 | end 23 | 24 | context 'after being saved' do 25 | # make sure it looks like an ActiveModel model 26 | include ActiveModel::Lint::Tests 27 | 28 | before :each do 29 | subject.save 30 | end 31 | 32 | it { is_expected.to be_persisted } 33 | it { is_expected.to eq(subject.class.find_by_id(subject.id)) } 34 | it { is_expected.to be_valid } 35 | 36 | it 'should be found in the database' do 37 | expect(subject.class.all.to_a).to include(subject) 38 | end 39 | 40 | it { is_expected.to respond_to(:to_param) } 41 | 42 | # it "should respond to primary_key" do 43 | # subject.class.should respond_to(:primary_key) 44 | # end 45 | 46 | it 'should render as XML' do 47 | expect(subject.to_xml).to match(/^<\?xml version=/) if subject.respond_to?(:to_xml) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/unit/attribute_set_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ActiveGraph::AttributeSet do 4 | let(:attr_hash) { {halley: 1986} } 5 | let(:attr_list) { [:halley, :icarus_year] } 6 | subject { ActiveGraph::AttributeSet.new(attr_hash, attr_list) } 7 | 8 | describe '#method_missing' do 9 | let(:delegated_hash) { subject.instance_variable_get(:@attributes).send(:materialize) } 10 | 11 | it 'delegates method_missing to attribute Hash' do 12 | expect(delegated_hash).to receive(:key?).with('name') 13 | subject.key?('name') 14 | end 15 | 16 | it 'delegates keyword arguments to attribute Hash' do 17 | expect(delegated_hash).to receive(:merge).with(icarus_year: 1566) 18 | subject.merge(icarus_year: 1566) 19 | end 20 | 21 | it 'delegates block to attribute Hash' do 22 | called = false 23 | block = ->(_) { called = true } 24 | expect(delegated_hash).to receive(:fetch_values).and_call_original 25 | subject.fetch_values(false, &block) 26 | expect(called).to be true 27 | end 28 | end 29 | 30 | describe 'marshalling' do 31 | it 'marshal dump and loads correctly' do 32 | marshalled_obj = Marshal.load(Marshal.dump(subject)) 33 | expect(subject).to eq(marshalled_obj) 34 | end 35 | end 36 | 37 | describe 'equality' do 38 | it "doesn't error while comparing with hash" do 39 | allow(subject).to receive(:to_hash) { attr_hash } 40 | expect(attr_hash == subject).to be true 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /docs/_yard/custom_templates/default/fulldoc/rst/module.erb: -------------------------------------------------------------------------------- 1 | <%= @module.name %> 2 | <%= '=' * @module.name.size %> 3 | 4 | <% @path = @path.empty? ? @module.name.to_s : File.join(@path, @module.name.to_s) %> 5 | 6 | <%= @module.docstring %> 7 | 8 | <% if @module.respond_to?(:children) %> 9 | .. toctree:: 10 | :maxdepth: 3 11 | :titlesonly: 12 | 13 | <% @module.children.each do |child| %> 14 | <%= File.join(@module.name.to_s, child.name.to_s) if child.is_a?(YARD::CodeObjects::NamespaceObject) %> 15 | <% end %> 16 | <% end %> 17 | 18 | 19 | Constants 20 | --------- 21 | 22 | <% if @module.respond_to?(:constants) %> 23 | <% @module.constants.each do |constant| %> 24 | * <%= constant.name %> 25 | <% end %> 26 | <% end %> 27 | 28 | Files 29 | ----- 30 | 31 | <% if @module.respond_to?(:files) %> 32 | <% @module.files.each do |path, line| %> 33 | * `<%= path %>:<%= line %> #L<%= line %>>`_ 34 | <% end %> 35 | <% end %> 36 | 37 | 38 | 39 | Methods 40 | ------- 41 | <% if @module.respond_to?(:meths) %> 42 | <% @module.meths.select {|meth| meth.visibility == :public }.sort_by {|m| m.name.to_s }.each do |meth| %> 43 | <% method_char = (meth.scope == :class ? '.' : '#') %> 44 | .. _`<%= @path %><%= method_char %><%= meth.name %>`: 45 | 46 | **<%= method_char %><%= meth.name %>** 47 | <%= meth.base_docstring.gsub(/([\n\r])/, '\1 ') %> 48 | 49 | .. code-block:: ruby 50 | 51 | <%= meth.source.gsub(/([\n\r])/, '\1 ') %> 52 | 53 | <% end %> 54 | <% end %> 55 | 56 | 57 | -------------------------------------------------------------------------------- /lib/active_graph/shared/rel_query_factory.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | class RelQueryFactory < QueryFactory 3 | protected 4 | 5 | def match_string 6 | "(#{graph_object.from_node_identifier})-[#{identifier}]->()" 7 | end 8 | 9 | def create_query 10 | return match_query if graph_object.persisted? 11 | create_props, set_props = filtered_props 12 | base_query.send(graph_object.create_method, query_string(create_props)).break 13 | .set(identifier => set_props) 14 | .params(params(create_props)) 15 | end 16 | 17 | private 18 | 19 | def filtered_props 20 | ActiveGraph::Shared::FilteredHash.new(graph_object.props_for_create, graph_object.creates_unique_option).filtered_base 21 | end 22 | 23 | def query_string(create_props) 24 | "(#{graph_object.from_node_identifier})-[#{identifier}:`#{graph_object.type}` #{pattern(create_props)}]->(#{graph_object.to_node_identifier})" 25 | end 26 | 27 | def params(create_props) 28 | unique? ? create_props.transform_keys { |key| scoped(key).to_sym } : { namespace.to_sym => create_props } 29 | end 30 | 31 | def unique? 32 | graph_object.create_method == :create_unique 33 | end 34 | 35 | def pattern(create_props) 36 | unique? ? "{#{create_props.keys.map { |key| "#{key}: $#{scoped(key)}" }.join(', ')}}" : "$#{namespace}" 37 | end 38 | 39 | def scoped(key) 40 | "#{namespace}_#{key}" 41 | end 42 | 43 | def namespace 44 | "#{identifier}_create_props" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/active_graph/core/logging.rb: -------------------------------------------------------------------------------- 1 | # Copied largely from activerecord/lib/active_record/log_subscriber.rb 2 | module ActiveGraph 3 | module Core 4 | module Logging 5 | class << self 6 | def first_external_path_and_line(callstack) 7 | line = callstack.find do |frame| 8 | frame.absolute_path && !ignored_callstack(frame.absolute_path) 9 | end 10 | 11 | offending_line = line || callstack.first 12 | 13 | [offending_line.path, 14 | offending_line.lineno] 15 | end 16 | 17 | NEO4J_CORE_GEM_ROOT = File.expand_path('../../..', __dir__) + '/' 18 | 19 | def ignored_callstack(path) 20 | paths_to_ignore.any?(&path.method(:start_with?)) 21 | end 22 | 23 | def paths_to_ignore 24 | @paths_to_ignore ||= [NEO4J_CORE_GEM_ROOT, 25 | RbConfig::CONFIG['rubylibdir'], 26 | neo4j_gem_path, 27 | active_support_gem_path].compact 28 | end 29 | 30 | def neo4j_gem_path 31 | return if !defined?(::Rails.root) 32 | 33 | @neo4j_gem_path ||= File.expand_path('../../..', ActiveGraph::Base.method(:driver).source_location[0]) 34 | end 35 | 36 | def active_support_gem_path 37 | return if !defined?(::ActiveSupport::Notifications) 38 | 39 | @active_support_gem_path ||= File.expand_path('../../..', ActiveSupport::Notifications.method(:subscribe).source_location[0]) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'bundler/gem_tasks' 3 | require 'neo4j/rake_tasks' 4 | # load 'neo4j/tasks/migration.rake' 5 | 6 | desc 'Generate YARD documentation' 7 | 8 | require 'colored' 9 | 10 | def system_or_fail(command) 11 | puts 'Running command: '.blue + command 12 | system(command) or fail "Unable to run: #{command}" # rubocop:disable Style/AndOr 13 | end 14 | 15 | namespace :docs do 16 | task :yard do 17 | system_or_fail('rm -rf docs/_build/_yard/') 18 | abort("can't generate YARD") unless system('yard -p docs/_yard/custom_templates -f rst') 19 | end 20 | 21 | task :sphinx do 22 | system_or_fail('rm -rf docs/api/') 23 | system_or_fail('cp -r docs/_build/_yard/ docs/api/') 24 | abort("can't generate Sphinx docs") unless system('cd docs && make html') 25 | system_or_fail('cp -r docs/assets/* docs/_build/html/_static/') 26 | end 27 | 28 | task :open do 29 | `open docs/_build/html/index.html` 30 | end 31 | 32 | task all: [:yard, :sphinx] 33 | end 34 | 35 | task docs: 'docs:all' 36 | 37 | desc 'Run neo4j.rb specs' 38 | task 'spec' do 39 | success = system('rspec spec') 40 | abort('RSpec neo4j failed') unless success 41 | end 42 | 43 | require 'rake/testtask' 44 | Rake::TestTask.new(:test_generators) do |test| 45 | test.libs << 'lib' << 'test' 46 | test.pattern = 'test/**/*_test.rb' 47 | test.verbose = true 48 | end 49 | 50 | desc 'Generate coverage report' 51 | task 'coverage' do 52 | ENV['COVERAGE'] = 'true' 53 | rm_rf 'coverage/' 54 | task = Rake::Task['spec'] 55 | task.reenable 56 | task.invoke 57 | end 58 | 59 | task default: ['spec'] 60 | -------------------------------------------------------------------------------- /spec/shared_examples/forbidden_attributes_shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'handles permitted parameters' do 2 | describe '#new' do 3 | it 'assigns permitted params' do 4 | params.permit! 5 | expect(klass.new(create_params).attributes).to include(params.to_h) 6 | end 7 | 8 | it 'fails on unpermitted parameters' do 9 | expect { klass.new(create_params) }.to raise_error ActiveModel::ForbiddenAttributesError 10 | end 11 | end 12 | 13 | describe '#create' do 14 | it 'assigns permitted params' do 15 | params.permit! 16 | expect(klass.create(create_params).attributes).to include(params.to_h) 17 | end 18 | 19 | it 'fails on unpermitted parameters' do 20 | expect { klass.create(create_params) }.to raise_error ActiveModel::ForbiddenAttributesError 21 | end 22 | end 23 | 24 | describe '#attributes=' do 25 | it 'assigns permitted params' do 26 | params.permit! 27 | subject.attributes = params 28 | expect(subject.attributes).to include(params.to_h) 29 | end 30 | 31 | it 'fails on unpermitted parameters' do 32 | expect { subject.attributes = params }.to raise_error ActiveModel::ForbiddenAttributesError 33 | end 34 | end 35 | 36 | describe '#update' do 37 | it 'assigns permitted params' do 38 | params.permit! 39 | subject.update(params) 40 | expect(subject.attributes).to include(params.to_h) 41 | end 42 | 43 | it 'fails on unpermitted parameters' do 44 | expect { klass.new.update(params) }.to raise_error ActiveModel::ForbiddenAttributesError 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /e2e_tests/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | gem install rails -v $ACTIVE_MODEL_VERSION --no-document 4 | 5 | if [[ -n "$ACTIVEGRAPH_PATH" ]] 6 | then 7 | sed 's|.*gem '"'"'activegraph'"'"'.*|gem '"'"'activegraph'"'"', path: "'"$ACTIVEGRAPH_PATH"'"|' docs/activegraph.rb > template.tmp 8 | else 9 | echo "SHA=$(git rev-parse "$GITHUB_SHA")" >> $GITHUB_ENV 10 | sed 's/.*gem '"'"'activegraph'"'"'.*/gem '"'"'activegraph'"'"', github: "neo4jrb\/activegraph", ref: "'"$(git rev-parse "$GITHUB_SHA")"'"/' docs/activegraph.rb > template.tmp 11 | fi 12 | 13 | rails \_$ACTIVE_MODEL_VERSION\_ new myapp -O -m ./template.tmp 14 | rm -f ./template.tmp 15 | cd myapp 16 | 17 | if [[ -n "$E2E_PORT" ]] 18 | then 19 | sed 's/7687/'$E2E_PORT'/' config/environments/development.rb > dev_env.tmp 20 | mv dev_env.tmp config/environments/development.rb 21 | fi 22 | 23 | if [[ -n "$E2E_NO_CRED" ]] 24 | then 25 | sed "s/'neo4j'/''/" config/environments/development.rb > dev_env.tmp 26 | mv dev_env.tmp config/environments/development.rb 27 | sed "s/'password'/''/" config/environments/development.rb > dev_env.tmp 28 | mv dev_env.tmp config/environments/development.rb 29 | fi 30 | 31 | bundle exec rails generate model User name:string 32 | bundle exec rails generate migration BlahMigration 33 | bundle exec rake neo4j:migrate 34 | 35 | if echo 'puts "hi"' | bundle exec rails c 36 | then 37 | echo "rails console works correctly" 38 | else 39 | exit 1 40 | fi 41 | 42 | bundle exec rails s -d 43 | until $(curl --output /dev/null --silent --head --fail localhost:3000); do 44 | printf '.' 45 | sleep 1 46 | done 47 | kill `cat tmp/pids/server.pid` 48 | -------------------------------------------------------------------------------- /spec/e2e/marshal_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Shared::Marshal, :ffi_only do 2 | describe 'Node' do 3 | before do 4 | stub_node_class('Parent') 5 | 6 | stub_named_class('Child', Parent) do 7 | property :foo 8 | end 9 | end 10 | 11 | let(:node) { Child.create(foo: 'bar') } 12 | 13 | it 'marshals correctly' do 14 | id = node.id 15 | neo_id = node.neo_id 16 | unmarshaled = Marshal.load(Marshal.dump(node)) 17 | 18 | expect(unmarshaled).to be_a(Child) 19 | expect(unmarshaled.id).to eq(id) 20 | expect(unmarshaled.neo_id).to eq(neo_id) 21 | expect(unmarshaled.foo).to eq('bar') 22 | expect(unmarshaled.labels).to match_array([:Parent, :Child]) 23 | expect(unmarshaled._persisted_obj).to be_a(ActiveGraph::Core::Node) 24 | end 25 | end 26 | 27 | describe 'Relationship' do 28 | before do 29 | stub_node_class('Person') 30 | 31 | stub_relationship_class('HasParent') do 32 | from_class :Person 33 | to_class :Person 34 | 35 | property :foo 36 | end 37 | end 38 | 39 | let(:rel) { HasParent.create(Person.create, Person.create, foo: 'bar') } 40 | 41 | it 'marshals correctly' do 42 | neo_id = rel.neo_id 43 | unmarshaled = Marshal.load(Marshal.dump(rel)) 44 | 45 | expect(unmarshaled).to be_a(HasParent) 46 | expect(unmarshaled.neo_id).to eq(neo_id) 47 | expect(unmarshaled.foo).to eq('bar') 48 | expect(unmarshaled.type).to eq('HAS_PARENT') 49 | expect(unmarshaled._persisted_obj).to be_a(Neo4j::Driver::Types::Relationship) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/active_graph/node/wrapping.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Wrapping 4 | # Only load classes once for performance 5 | CONSTANTS_FOR_LABELS_CACHE = {} 6 | 7 | class << self 8 | def wrapper(node) 9 | found_class = class_to_wrap(node.labels) 10 | return node unless found_class 11 | 12 | found_class.new.tap do |wrapped_node| 13 | wrapped_node.init_on_load(node, node.properties) 14 | end 15 | end 16 | 17 | def class_to_wrap(labels) 18 | load_classes_from_labels(labels) 19 | ActiveGraph::Node::Labels.model_for_labels(labels).tap do |model_class| 20 | populate_constants_for_labels_cache(model_class, labels) 21 | end 22 | end 23 | 24 | private 25 | 26 | def load_classes_from_labels(labels) 27 | labels.each { |label| constant_for_label(label) } 28 | end 29 | 30 | def constant_for_label(label) 31 | CONSTANTS_FOR_LABELS_CACHE[label] ||= constantized_label(label) 32 | end 33 | 34 | def constantized_label(label) 35 | "#{association_model_namespace}::#{label}".constantize 36 | rescue NameError, LoadError 37 | nil 38 | end 39 | 40 | def populate_constants_for_labels_cache(model_class, labels) 41 | labels.each do |label| 42 | CONSTANTS_FOR_LABELS_CACHE[label] ||= model_class 43 | end 44 | end 45 | 46 | def association_model_namespace 47 | ActiveGraph::Config.association_model_namespace_string 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/active_graph/shared/validations.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | module Validations 4 | extend ActiveSupport::Concern 5 | include ActiveModel::Validations 6 | # Implements the ActiveModel::Validation hook method. 7 | # @see http://rubydoc.info/docs/rails/ActiveModel/Validations:read_attribute_for_validation 8 | def read_attribute_for_validation(key) 9 | respond_to?(key) ? send(key) : self[key] 10 | end 11 | 12 | # The validation process on save can be skipped by passing false. The regular Model#save method is 13 | # replaced with this when the validations module is mixed in, which it is by default. 14 | # @param [Hash] options the options to create a message with. 15 | # @option options [true, false] :validate if false no validation will take place 16 | # @return [Boolean] true if it saved it successfully 17 | def save(options = {}) 18 | perform_validations(options) ? super : false 19 | end 20 | 21 | # @return [Boolean] true if valid 22 | def valid?(context = nil) 23 | context ||= (new_record? ? :create : :update) 24 | super(context) 25 | errors.empty? 26 | end 27 | 28 | private 29 | 30 | def perform_validations(options = {}) 31 | perform_validation = case options 32 | when Hash 33 | options[:validate] != false 34 | end 35 | 36 | if perform_validation 37 | valid?(options.is_a?(Hash) ? options[:context] : nil) 38 | else 39 | true 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/active_graph/node/unpersisted.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Unpersisted 4 | # The values in this Hash are returned and used outside by reference 5 | # so any modifications to the Array should be in-place 6 | def deferred_create_cache 7 | @deferred_create_cache ||= {} 8 | end 9 | 10 | def defer_create(association_name, object, options = {}) 11 | clear_deferred_nodes_for_association(association_name) if options[:clear] 12 | 13 | deferred_nodes_for_association(association_name).concat(Array(object)) 14 | end 15 | 16 | def deferred_nodes_for_association(association_name) 17 | deferred_create_cache[association_name.to_sym] ||= [] 18 | end 19 | 20 | def pending_deferred_creations? 21 | !deferred_create_cache.values.all?(&:empty?) 22 | end 23 | 24 | def clear_deferred_nodes_for_association(association_name) 25 | deferred_nodes_for_association(association_name.to_sym).clear 26 | end 27 | 28 | private 29 | 30 | def process_unpersisted_nodes! 31 | deferred_create_cache.dup.each do |association_name, nodes| 32 | association_proxy = association_proxy(association_name) 33 | 34 | nodes.each do |node| 35 | if node.respond_to?(:changed?) 36 | node.save if node.changed? || !node.persisted? 37 | fail "Unable to defer node persistence, could not save #{node.inspect}" unless node.persisted? 38 | end 39 | 40 | association_proxy << node 41 | end 42 | end 43 | 44 | @deferred_create_cache = {} 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.github/workflows/e2e_test.yml: -------------------------------------------------------------------------------- 1 | name: E2E Test 2 | 3 | on: 4 | push: 5 | branches: [ '11' ] 6 | pull_request: 7 | branches: [ '11' ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | e2e_test: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 30 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby: [ jruby-9.4.5.0, ruby-3.2.3 ] 20 | neo4j: [ 5.16.0 ] 21 | active_model: [ 7.1.3 ] 22 | # jruby will fail till bug https://github.com/jruby/jruby-openssl/issues/290 is fixed 23 | env: 24 | ACTIVE_MODEL_VERSION: ${{ matrix.active_model }} 25 | JRUBY_OPTS: --debug -J-Xmx1280m -Xcompile.invokedynamic=false -J-XX:+TieredCompilation -J-XX:TieredStopAtLevel=1 -J-noverify -Xcompile.mode=OFF 26 | steps: 27 | - name: Start neo4j 28 | run: docker run --name neo4j --env NEO4J_AUTH=neo4j/password --env NEO4J_ACCEPT_LICENSE_AGREEMENT=yes --env NEO4J_dbms_directories_import= -p7687:7687 -p7474:7474 -v `pwd`/tmp:/var/lib/neo4j/import --rm neo4j:${{ matrix.neo4j }}-enterprise & 29 | 30 | - uses: actions/checkout@v3 31 | 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby }} 36 | 37 | - name: Wait for neo4j 38 | run: while [ $((curl localhost:7474/ > /dev/null 2>&1); echo $?) -ne 0 ]; do sleep 1; done 39 | 40 | - name: Setup test rails app 41 | run: ./e2e_tests/setup.sh 42 | 43 | - name: Install dependencies 44 | run: bundle update 45 | 46 | - name: Run tests 47 | run: bundle exec rspec -Oe2e_tests/.e2e_rspec e2e_tests/ 48 | -------------------------------------------------------------------------------- /config/neo4j/config.yml: -------------------------------------------------------------------------------- 1 | #=== Neo4j.rb configuration settings 2 | 3 | 4 | # Examples of not using the Neo4j id (neo_id) 5 | 6 | # Generated UUID stored as a neo4j property on my_id 7 | #id_property: my_id 8 | #id_property_type: :auto 9 | #id_property_type_value: :uuid 10 | 11 | # Example, (probably more useful directly on ActiveGraph::Node classes instead as a global configuration) 12 | #id_property: title_id 13 | #id_property_type: :on 14 | #id_property_type_value: :some_method 15 | 16 | # TODO 17 | # if identity map should be on or not 18 | # It may impact the performance. Using the identity map will keep all loaded wrapper node/relationship 19 | # object in memory for each thread and transaction - which may speed up or slow down operations. 20 | identity_map: false 21 | 22 | # TODO 23 | # When using the ActiveGraph::Model you can let neo4j automatically set timestamps when updating/creating nodes. 24 | # If set to true neo4j.rb automatically timestamps create and update operations if the model has properties named created_at/created_on or updated_at/updated_on 25 | # (similar to ActiveRecord). 26 | timestamps: true 27 | 28 | # Store a property on objects to cache their Node/Relationship class. It prevents each object load from requiring two database queries. 29 | # Strings shorter than 44 characters are classified, so this will have almost no impact on disk footprint. See http://docs.neo4j.org/chunked/stable/short-strings.html. 30 | # Alternatively, call class method ActiveGraph::Node:cache_class to set this on specific models. 31 | # By default, this property is called _classname, set as symbol to override. 32 | cache_class_names: true 33 | # class_name_property: :_classname 34 | 35 | transform_rel_type: :upcase 36 | -------------------------------------------------------------------------------- /lib/active_graph/core/query_find_in_batches.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Core 3 | module QueryFindInBatches 4 | def find_in_batches(node_var, prop_var, options = {}) 5 | validate_find_in_batches_options!(options) 6 | 7 | batch_size = options.delete(:batch_size) || 1000 8 | 9 | query = reorder(node_var => prop_var).limit(batch_size) 10 | 11 | records = query.to_a 12 | 13 | while records.any? 14 | records_size = records.size 15 | primary_key_offset = primary_key_offset(records.last, node_var, prop_var) 16 | 17 | yield records 18 | 19 | break if records_size < batch_size 20 | 21 | primary_key_var = ActiveGraph::Core::QueryClauses::Clause.from_key_and_single_value(node_var, prop_var) 22 | records = query.where("#{primary_key_var} > $primary_key_offset") 23 | .params(primary_key_offset: primary_key_offset).to_a 24 | end 25 | end 26 | 27 | def find_each(*args, &block) 28 | find_in_batches(*args) { |batch| batch.each(&block) } 29 | end 30 | 31 | private 32 | 33 | def validate_find_in_batches_options!(options) 34 | invalid_keys = options.keys.map(&:to_sym) - [:batch_size] 35 | fail ArgumentError, "Invalid keys: #{invalid_keys.join(', ')}" if not invalid_keys.empty? 36 | end 37 | 38 | def primary_key_offset(last_record, node_var, prop_var) 39 | node = last_record[node_var] 40 | return node.send(prop_var) if node&.respond_to?(prop_var) 41 | return node.properties[prop_var.to_sym] if node&.respond_to?(:properties) 42 | last_record["#{node_var}.#{prop_var}"] # In case we're explicitly returning it 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/unit/node/query_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Query do 2 | let(:driver) { double('Driver') } 3 | 4 | before(:all) do 5 | @prev_wrapped_classes = ActiveGraph::Node::Labels._wrapped_classes 6 | ActiveGraph::Node::Labels._wrapped_classes.clear 7 | 8 | @class_a = Class.new do 9 | include ActiveGraph::Node::Query 10 | def neo_id 11 | 8724 12 | end 13 | 14 | def self.name 15 | 'Person' 16 | end 17 | 18 | def self.neo4j_driver 19 | driver 20 | end 21 | end 22 | end 23 | 24 | after(:all) do 25 | # restore 26 | ActiveGraph::Node::Labels._wrapped_classes.concat(@prev_wrapped_classes) 27 | end 28 | 29 | describe '.query_as' do 30 | it 'generates a basic query with labels' do 31 | expect(@class_a.query_as(:q).to_cypher).to eq('MATCH (q:`Person`)') 32 | end 33 | 34 | it 'includes labels when :neo_id is not present' do 35 | expect(@class_a.query_as(:q).to_cypher).to include('Person') 36 | end 37 | 38 | it 'can be built upon' do 39 | expect(@class_a.query_as(:q).match('q--p').where(p: {name: 'Brian'}).to_cypher).to eq('MATCH (q:`Person`), q--p WHERE (p.name = $p_name)') 40 | end 41 | end 42 | 43 | describe '#query_as' do 44 | it 'generates a basic query with labels' do 45 | expect(@class_a.new.query_as(:q).to_cypher).to eq('MATCH (q) WHERE (ID(q) = $ID_q)') 46 | end 47 | 48 | it 'can be built upon' do 49 | expect(@class_a.new.query_as(:q).match('(q)--(p)').return(p: :name).to_cypher).to eq('MATCH (q), (q)--(p) WHERE (ID(q) = $ID_q) RETURN p.name') 50 | end 51 | 52 | it 'does not include labels' do 53 | expect(@class_a.new.query_as(:q).to_cypher).not_to include('Person') 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/active_graph/node/dependent/association_methods.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | module Dependent 4 | module AssociationMethods 5 | def validate_dependent(value) 6 | fail ArgumentError, "Invalid dependent value: #{value.inspect}" if not valid_dependent_value?(value) 7 | end 8 | 9 | def add_destroy_callbacks(model) 10 | return if dependent.nil? 11 | 12 | model.before_destroy(&method("dependent_#{dependent}_callback")) 13 | rescue NameError 14 | raise "Unknown dependent option #{dependent}" 15 | end 16 | 17 | private 18 | 19 | def valid_dependent_value?(value) 20 | return true if value.nil? 21 | 22 | self.respond_to?("dependent_#{value}_callback", true) 23 | end 24 | 25 | # Callback methods 26 | def dependent_delete_callback(object) 27 | object.association_query_proxy(name).delete_all 28 | end 29 | 30 | def dependent_delete_orphans_callback(object) 31 | unique_query = object.as(:self).unique_nodes(self, :self, :n, :other_rel) 32 | unique_query.query.optional_match('(n)-[r]-()').delete(:n, :r).exec if unique_query 33 | end 34 | 35 | def dependent_destroy_callback(object) 36 | unique_query = object.association_query_proxy(name) 37 | unique_query.each_for_destruction(object, &:destroy) if unique_query 38 | end 39 | 40 | def dependent_destroy_orphans_callback(object) 41 | unique_query = object.as(:self).unique_nodes(self, :self, :n, :other_rel) 42 | unique_query.each_for_destruction(object, &:destroy) if unique_query 43 | end 44 | 45 | # End callback methods 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/active_graph.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require 'bigdecimal' 3 | require 'bigdecimal/util' 4 | require 'date' 5 | require 'forwardable' 6 | require 'active_model' 7 | require 'active_model/attribute_set' 8 | require 'active_support/core_ext/big_decimal/conversions' 9 | require 'active_support/core_ext/class/attribute' 10 | require 'active_support/core_ext/class/subclasses' 11 | require 'active_support/core_ext/module/attribute_accessors' 12 | require 'active_support/core_ext/module/attribute_accessors_per_thread' 13 | require 'active_support/core_ext/string/conversions' 14 | require 'active_support/inflector' 15 | require 'active_support/inflector/inflections' 16 | require 'active_support/notifications' 17 | require 'json' 18 | require 'neo4j/driver' 19 | require 'orm_adapter' 20 | require 'rake' 21 | require 'set' 22 | require 'sorted_set' 23 | require 'yaml' 24 | 25 | loader = Zeitwerk::Loader.for_gem 26 | loader.ignore(File.expand_path('rails', __dir__)) 27 | loader.ignore(File.expand_path('active_graph/railtie.rb', __dir__)) 28 | loader.inflector.inflect("ansi" => "ANSI") 29 | module ActiveGraph 30 | end 31 | loader.setup 32 | # loader.eager_load 33 | 34 | Neo4j::Driver::Result.prepend ActiveGraph::Core::Result 35 | Neo4j::Driver::Record.prepend ActiveGraph::Core::Record 36 | Neo4j::Driver::Transaction.prepend ActiveGraph::Transaction 37 | Neo4j::Driver::Types::Entity.include ActiveGraph::Core::Wrappable 38 | Neo4j::Driver::Types::Entity.prepend ActiveGraph::Core::Entity 39 | Neo4j::Driver::Types::Node.prepend ActiveGraph::Core::Node 40 | Neo4j::Driver::Types::Node.wrapper_callback(&ActiveGraph::Node::Wrapping.method(:wrapper)) 41 | Neo4j::Driver::Types::Relationship.wrapper_callback(&ActiveGraph::Relationship::Wrapping.method(:wrapper)) 42 | SecureRandom.singleton_class.prepend ActiveGraph::SecureRandomExt 43 | 44 | load 'active_graph/tasks/migration.rake' 45 | -------------------------------------------------------------------------------- /spec/neo4j_spec_helpers.rb: -------------------------------------------------------------------------------- 1 | module Neo4jSpecHelpers 2 | extend ActiveSupport::Concern 3 | 4 | class << self 5 | attr_accessor :expect_queries_count 6 | end 7 | 8 | self.expect_queries_count = 0 9 | 10 | ActiveGraph::Base.subscribe_to_query do |_message| 11 | self.expect_queries_count += 1 12 | end 13 | 14 | def expect_queries(count, &block) 15 | expect(queries_count(&block)).to eq(count) 16 | end 17 | 18 | def queries_count 19 | start_count = Neo4jSpecHelpers.expect_queries_count 20 | yield 21 | Neo4jSpecHelpers.expect_queries_count - start_count 22 | end 23 | 24 | def new_query 25 | ActiveGraph::Base.new_query 26 | end 27 | 28 | def driver 29 | ActiveGraph::Base.driver 30 | end 31 | 32 | def neo4j_query(*args) 33 | ActiveGraph::Base.query(*args) 34 | end 35 | 36 | def action_controller_params(args) 37 | ActionController::Parameters.new(args) 38 | end 39 | 40 | class_methods do 41 | def let_config(var_name, value) 42 | around do |example| 43 | old_value = ActiveGraph::Config[var_name] 44 | ActiveGraph::Config[var_name] = value 45 | example.run 46 | ActiveGraph::Config[var_name] = old_value 47 | end 48 | end 49 | 50 | def capture_output!(variable) 51 | around do |example| 52 | @captured_stream = StringIO.new 53 | 54 | original_stream = $stdout 55 | $stdout = @captured_stream 56 | 57 | example.run 58 | 59 | $stdout = original_stream 60 | end 61 | let(variable) { @captured_stream.string } 62 | end 63 | 64 | def let_env_variable(var_name) 65 | around do |example| 66 | old_value = ENV[var_name.to_s] 67 | ENV[var_name.to_s] = yield 68 | example.run 69 | ENV[var_name.to_s] = old_value 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ '11' ] 6 | pull_request: 7 | branches: [ '11' ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 30 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby: [ jruby-9.4.5.0, ruby-3.1.4 ] 20 | neo4j: [ 3.5.35, 4.0.12, 4.1.13, 4.2.19, 4.3.24, 4.4.29, 5.16.0 ] 21 | active_model: [ 7.0.8, 7.1.3 ] 22 | include: 23 | - ruby: ruby-3.2.3 24 | neo4j: 5.16.0 25 | active_model: 7.1.3 26 | - ruby: ruby-3.3.0 27 | neo4j: 5.16.0 28 | active_model: 7.1.3 29 | env: 30 | NEO4J_VERSION: ${{ matrix.neo4j }} 31 | ACTIVE_MODEL_VERSION: ${{ matrix.active_model }} 32 | JRUBY_OPTS: --debug -J-Xmx1280m -Xcompile.invokedynamic=false -J-XX:+TieredCompilation -J-XX:TieredStopAtLevel=1 -J-noverify -Xcompile.mode=OFF 33 | steps: 34 | - name: Start neo4j 35 | run: docker run --name neo4j --env NEO4J_AUTH=neo4j/password --env NEO4J_ACCEPT_LICENSE_AGREEMENT=yes --env NEO4J_dbms_directories_import= -p7687:7687 -p7474:7474 -v `pwd`/tmp:/var/lib/neo4j/import --rm neo4j:${{ matrix.neo4j }}-enterprise & 36 | 37 | - uses: actions/checkout@v3 38 | 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby }} 43 | 44 | - name: Install dependencies 45 | run: bundle update 46 | 47 | - name: Wait for neo4j 48 | run: while [ $((curl localhost:7474/ > /dev/null 2>&1); echo $?) -ne 0 ]; do sleep 1; done 49 | 50 | - name: Run tests 51 | run: bundle exec rspec 52 | 53 | # - name: Run tests 54 | # run: bundle exec rubocop 55 | -------------------------------------------------------------------------------- /lib/active_graph/error.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | # Neo4j.rb Errors 3 | # Generic Neo4j.rb exception class. 4 | class Error < StandardError 5 | end 6 | 7 | # Raised when Neo4j.rb cannot find record by given id. 8 | class RecordNotFound < Error 9 | attr_reader :model, :primary_key, :id 10 | 11 | def initialize(message = nil, model = nil, primary_key = nil, id = nil) 12 | @primary_key = primary_key 13 | @model = model 14 | @id = id 15 | 16 | super(message) 17 | end 18 | end 19 | 20 | class DeprecatedSchemaDefinitionError < Error; end 21 | 22 | class InvalidPropertyOptionsError < Error; end 23 | 24 | class InvalidParameterError < Error; end 25 | 26 | class UnknownTypeConverterError < Error; end 27 | 28 | class DangerousAttributeError < ScriptError; end 29 | class UnknownAttributeError < NoMethodError; end 30 | 31 | class MigrationError < Error; end 32 | class IrreversibleMigration < MigrationError; end 33 | class UnknownMigrationVersionError < MigrationError; end 34 | 35 | # Inspired/taken from active_record/migration.rb 36 | class PendingMigrationError < MigrationError 37 | def initialize(migrations) 38 | pending_migrations = migrations.join("\n") 39 | if rails? && defined?(Rails.env) 40 | super("Migrations are pending:\n#{pending_migrations}\n To resolve this issue, run:\n\n #{command_name} neo4j:migrate RAILS_ENV=#{::Rails.env}") 41 | else 42 | super("Migrations are pending:\n#{pending_migrations}\n To resolve this issue, run:\n\n #{command_name} neo4j:migrate") 43 | end 44 | end 45 | 46 | private 47 | 48 | def command_name 49 | return 'rake' unless rails? 50 | Rails.version.to_f >= 5 ? 'bin/rails' : 'bin/rake' 51 | end 52 | 53 | def rails? 54 | defined?(Rails) 55 | end 56 | end 57 | 58 | class Rollback < Error; end 59 | end 60 | -------------------------------------------------------------------------------- /lib/active_graph/node/id_property/accessor.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node::IdProperty 2 | # Provides get/set of the Id Property values. 3 | # Some methods 4 | module Accessor 5 | extend ActiveSupport::Concern 6 | 7 | attr_reader :default_property_value 8 | 9 | def default_properties=(properties) 10 | @default_property_value = properties[default_property_key] 11 | end 12 | 13 | def default_property(key) 14 | return nil unless key == default_property_key 15 | default_property_value 16 | end 17 | 18 | def default_property_key 19 | self.class.default_property_key 20 | end 21 | 22 | def default_properties 23 | @default_properties ||= Hash.new(nil) 24 | end 25 | 26 | module ClassMethods 27 | def default_property_key 28 | @default_property_key ||= default_properties_keys.first 29 | end 30 | 31 | # TODO: Move this to the DeclaredProperties 32 | def default_property(name, &block) 33 | reset_default_properties(name) if default_properties.respond_to?(:size) 34 | default_properties[name] = block 35 | end 36 | 37 | # @return [Hash] 38 | def default_properties 39 | @default_property ||= {} 40 | end 41 | 42 | def default_properties_keys 43 | @default_properties_keys ||= default_properties.keys 44 | end 45 | 46 | def reset_default_properties(name_to_keep) 47 | default_properties.each_key do |property| 48 | @default_properties_keys = nil 49 | undef_method(property) unless property == name_to_keep 50 | end 51 | @default_properties_keys = nil 52 | @default_property = {} 53 | end 54 | 55 | def default_property_values(instance) 56 | default_properties.each_with_object({}) do |(key, block), result| 57 | result[key] = block.call(instance) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/active_graph/shared/typecaster.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | # This module provides a convenient way of registering a custom Typecasting class. Custom Typecasters all follow a simple pattern. 4 | # 5 | # EXAMPLE: 6 | # 7 | # .. code-block:: ruby 8 | # 9 | # class RangeConverter 10 | # class << self 11 | # def primitive_type 12 | # String 13 | # end 14 | # 15 | # def convert_type 16 | # Range 17 | # end 18 | # 19 | # def to_db(value) 20 | # value.to_s 21 | # end 22 | # 23 | # def to_ruby(value) 24 | # ends = value.to_s.split('..').map { |d| Integer(d) } 25 | # ends[0]..ends[1] 26 | # end 27 | # alias_method :call, :to_ruby 28 | # end 29 | # 30 | # include ActiveGraph::Shared::Typecaster 31 | # end 32 | # 33 | # This would allow you to use `property :my_prop, type: Range` in a model. 34 | # Each method and the `alias_method` call is required. Make sure the module inclusion happens at the end of the file. 35 | # 36 | # `primitive_type` is used to fool ActiveAttr's type converters, which only recognize a few basic Ruby classes. 37 | # 38 | # `convert_type` must match the constant given to the `type` option. 39 | # 40 | # `to_db` provides logic required to transform your value into the class defined by `primitive_type` 41 | # 42 | # `to_ruby` provides logic to transform the DB-provided value back into the class expected by code using the property. 43 | # In other words, it should match the `convert_type`. 44 | # 45 | # Note that `alias_method` is used to make `to_ruby` respond to `call`. This is to provide compatibility with ActiveAttr. 46 | 47 | module Typecaster 48 | def self.included(other) 49 | ActiveGraph::Shared::TypeConverters.register_converter(other) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/active_graph/shared/query_factory.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | # Acts as a bridge between the node and rel models and ActiveGraph::Core::Query. 3 | # If the object is persisted, it returns a query matching; otherwise, it returns a query creating it. 4 | # This class does not execute queries, so it keeps no record of what identifiers have been set or what has happened in previous factories. 5 | class QueryFactory 6 | attr_reader :graph_object, :identifier 7 | 8 | def initialize(graph_object, identifier) 9 | @graph_object = graph_object 10 | @identifier = identifier.to_sym 11 | end 12 | 13 | def self.create(graph_object, identifier) 14 | factory_for(graph_object).new(graph_object, identifier) 15 | end 16 | 17 | def self.factory_for(graph_obj) 18 | case 19 | when graph_obj.respond_to?(:labels_for_create) 20 | NodeQueryFactory 21 | when graph_obj.respond_to?(:type) 22 | RelQueryFactory 23 | else 24 | fail "Unable to find factory for #{graph_obj}" 25 | end 26 | end 27 | 28 | def query 29 | graph_object.persisted? ? match_query : create_query 30 | end 31 | 32 | # @param [ActiveGraph::Core::Query] query An instance of ActiveGraph::Core::Query upon which methods will be chained. 33 | def base_query=(query) 34 | return if query.blank? 35 | @base_query = query 36 | end 37 | 38 | def base_query 39 | @base_query || ActiveGraph::Base.new_query 40 | end 41 | 42 | protected 43 | 44 | def create_query 45 | fail 'Abstract class, not implemented' 46 | end 47 | 48 | def match_query 49 | base_query 50 | .match(match_string).where("ID(#{identifier}) = $#{identifier_id}") 51 | .params(identifier_id.to_sym => graph_object.neo_id) 52 | end 53 | 54 | def identifier_id 55 | @identifier_id ||= "#{identifier}_id" 56 | end 57 | 58 | def identifier_params 59 | @identifier_params ||= "#{identifier}_params" 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/unit/relationship/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Relationship::Callbacks do 2 | after(:all) do 3 | [:CallbackBar, :CallbackFoo].each do |s| 4 | Object.send(:remove_const, s) 5 | end 6 | end 7 | let(:driver) { double('Driver') } 8 | let(:node1) { double('Node1') } 9 | let(:node2) { double('Node2') } 10 | 11 | class CallbackFoo 12 | def initialize(_args = nil); end 13 | 14 | def save(*) 15 | true 16 | end 17 | end 18 | 19 | class CallbackBar < CallbackFoo 20 | include ActiveGraph::Relationship::Callbacks 21 | end 22 | 23 | describe 'save' do 24 | let(:rel) { CallbackBar.new } 25 | 26 | before do 27 | allow(CallbackBar).to receive(:neo4j_driver).and_return(driver) 28 | 29 | allow_any_instance_of(CallbackBar).to receive(:_persisted_obj).and_return(nil) 30 | allow_any_instance_of(CallbackBar).to receive_message_chain('errors.full_messages').and_return([]) 31 | end 32 | 33 | it 'raises an error if unpersisted and outbound is not valid' do 34 | allow_any_instance_of(CallbackBar).to receive_message_chain('to_node.neo_id') 35 | allow_any_instance_of(CallbackBar).to receive_message_chain('from_node').and_return(nil) 36 | expect { rel.save }.to raise_error(ActiveGraph::Relationship::Persistence::RelInvalidError) 37 | end 38 | 39 | it 'raises an error if unpersisted and inbound is not valid' do 40 | allow_any_instance_of(CallbackBar).to receive_message_chain('from_node.neo_id') 41 | allow_any_instance_of(CallbackBar).to receive_message_chain('to_node').and_return(nil) 42 | expect { rel.save }.to raise_error(ActiveGraph::Relationship::Persistence::RelInvalidError) 43 | end 44 | 45 | it 'does not raise an error if inbound and outbound are valid' do 46 | allow_any_instance_of(CallbackBar).to receive_message_chain('from_node.neo_id') 47 | allow_any_instance_of(CallbackBar).to receive_message_chain('to_node.neo_id') 48 | expect { rel.save }.not_to raise_error 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/active_graph/base.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | # To contain any base login for Node/Relationship which 3 | # is external to the main classes 4 | module Base 5 | include ActiveGraph::Transactions 6 | include ActiveGraph::Core::Querable 7 | extend ActiveGraph::Core::Schema 8 | 9 | at_exit do 10 | @driver&.close 11 | end 12 | 13 | class << self 14 | # private? 15 | def driver 16 | (@driver ||= establish_driver).tap do |driver| 17 | fail 'No driver defined!' if driver.nil? 18 | end 19 | end 20 | 21 | def on_establish_driver(&block) 22 | @establish_driver_block = block 23 | end 24 | 25 | def establish_driver 26 | @establish_driver_block.call if @establish_driver_block 27 | end 28 | 29 | def query(*args) 30 | transaction(implicit: true) do 31 | super(*args) 32 | end 33 | end 34 | 35 | # Should support setting driver via config options 36 | def driver=(driver) 37 | @driver&.close 38 | @driver = driver 39 | end 40 | 41 | def validating_transaction(&block) 42 | validate_model_schema! 43 | transaction(&block) 44 | end 45 | 46 | def new_query(options = {}) 47 | validate_model_schema! 48 | ActiveGraph::Core::Query.new(options) 49 | end 50 | 51 | def magic_query(*args) 52 | if args.empty? || args.map(&:class) == [Hash] 53 | new_query(*args) 54 | else 55 | query(*args) 56 | end 57 | end 58 | 59 | def label_object(label_name) 60 | ActiveGraph::Core::Label.new(label_name) 61 | end 62 | 63 | def logger 64 | @logger ||= (ActiveGraph::Config[:logger] || ActiveSupport::Logger.new(STDOUT)) 65 | end 66 | 67 | private 68 | 69 | def validate_model_schema! 70 | ActiveGraph::ModelSchema.validate_model_schema! unless ActiveGraph::Migrations.currently_running_migrations 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/active_graph/transactions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveGraph 4 | module Transactions 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | thread_mattr_accessor :explicit_session, :tx, :last_bookmark 9 | end 10 | 11 | class_methods do 12 | def session(**session_config) 13 | ActiveGraph::Base.driver.session(**session_config) do |session| 14 | self.explicit_session = session 15 | yield session 16 | ensure 17 | self.last_bookmark = session.last_bookmark 18 | end 19 | end 20 | 21 | def write_transaction(**config, &block) 22 | send_transaction(:write_transaction, **config, &block) 23 | end 24 | 25 | def read_transaction(**config, &block) 26 | send_transaction(:read_transaction, **config, &block) 27 | end 28 | 29 | alias transaction write_transaction 30 | 31 | def lock_node(node) 32 | node.as(:n).query.remove('n._AGLOCK_').exec if tx&.open? 33 | end 34 | 35 | private 36 | 37 | def send_transaction(method, **config, &block) 38 | return yield tx if tx&.open? 39 | return run_transaction_work(explicit_session, method, **config, &block) if explicit_session&.open? 40 | driver.session do |session| 41 | run_transaction_work(session, method, **config, &block) 42 | end 43 | end 44 | 45 | def run_transaction_work(session, method, **config, &block) 46 | implicit = config.delete(:implicit) 47 | session.send(method, **config) do |tx| 48 | self.tx = tx 49 | block.call(tx).tap do |result| 50 | if implicit && 51 | [Core::Result, ActiveGraph::Node::Query::QueryProxy, ActiveGraph::Core::Query] 52 | .any?(&result.method(:is_a?)) 53 | result.store 54 | end 55 | end 56 | end.tap { tx.apply_callbacks } 57 | rescue ActiveGraph::Rollback 58 | # rollbacks are silently swallowed 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/schema.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | module Schema 4 | class << self 5 | def fetch_schema_data 6 | %i[constraints indexes].to_h { |schema_elem| [schema_elem, send("fetch_#{schema_elem}_descriptions").keys] } 7 | end 8 | 9 | def synchronize_schema_data(schema_data, remove_missing) 10 | queries = 11 | ActiveGraph::Base.read_transaction do 12 | drop_and_create_queries(fetch_constraints_descriptions, schema_data[:constraints], 'CONSTRAINT', remove_missing) + 13 | drop_and_create_queries(fetch_indexes_descriptions, schema_data[:indexes], 'INDEX', remove_missing) 14 | end 15 | ActiveGraph::Base.write_transaction do 16 | queries.each(&ActiveGraph::Base.method(:query)) 17 | end 18 | end 19 | 20 | private 21 | 22 | def fetch_indexes_descriptions 23 | ActiveGraph::Base.raw_indexes.reject(&ActiveGraph::Base.method(:constraint_owned?)) 24 | .then(&ActiveGraph::Base.method(:normalize)).then(&method(:fetch_descriptions)) 25 | end 26 | 27 | def fetch_constraints_descriptions 28 | fetch_descriptions(ActiveGraph::Base.constraints) 29 | end 30 | 31 | def fetch_descriptions(results) 32 | results.map { |definition| definition.values_at(:create_statement, :name) }.sort.to_h 33 | end 34 | 35 | def drop_and_create_queries(existing, specified, schema_elem, remove_missing) 36 | (remove_missing ? existing.except(*specified).map { |stmt, name| drop_statement(schema_elem, stmt, name) } : []) + 37 | (specified - existing.keys).map(&method(:create_statement)) 38 | end 39 | 40 | def drop_statement(schema_elem, create_statement, name) 41 | "DROP #{name&.then { |name| "#{schema_elem} #{name}" } || create_statement}" 42 | end 43 | 44 | def create_statement(stmt) 45 | stmt.start_with?('CREATE ') ? stmt : "CREATE #{stmt}" 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/e2e/queries_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'ActiveGraph::Node#find' do 2 | before do 3 | clear_model_memory_caches 4 | end 5 | 6 | let(:clazz) do 7 | stub_node_class('Clazz') do 8 | property :name 9 | end 10 | end 11 | 12 | it 'can find nodes that exists' do 13 | foo = clazz.create(name: 'foo') 14 | expect(clazz.where(name: 'foo').first).to eq(foo) 15 | end 16 | 17 | it 'can not find nodes that does not exists' do 18 | expect(clazz.where(name: 'unkown').first).to be_nil 19 | end 20 | end 21 | 22 | 23 | describe 'ActiveGraph::Node#all' do 24 | before do 25 | clear_model_memory_caches 26 | end 27 | 28 | before do 29 | stub_node_class('ClazzA') do 30 | property :name 31 | property :score, type: Integer 32 | 33 | has_one :out, :knows, type: nil, model_class: false 34 | end 35 | 36 | stub_node_class('ClazzB') do 37 | property :name 38 | property :score, type: Integer 39 | 40 | has_many :in, :known_by, type: nil, model_class: false 41 | end 42 | end 43 | 44 | let!(:b2) { ClazzB.create(name: 'b2', score: '2') } 45 | let!(:b1) { ClazzB.create(name: 'b1', score: '1') } 46 | 47 | let!(:a2) { ClazzA.create(name: 'b2', score: '2', knows: b2) } 48 | let!(:a1) { ClazzA.create(name: 'b1', score: '1', knows: b1) } 49 | let!(:a4) { ClazzA.create(name: 'b4', score: '4', knows: b1) } 50 | let!(:a3) { ClazzA.create(name: 'b3', score: '3', knows: b2) } 51 | 52 | it 'can find nodes that exists' do 53 | expect(ClazzA.where(score: 1).to_a).to match_array([a1]) 54 | end 55 | 56 | it 'can sort them' do 57 | expect(ClazzA.order(:score).to_a).to eq([a1, a2, a3, a4]) 58 | end 59 | 60 | it 'can skip and limit result' do 61 | expect(ClazzA.order(:score).skip(1).limit(2).to_a).to eq([a2, a3]) 62 | end 63 | 64 | it 'can find all nodes having a relationship to another node' do 65 | expect(b2.known_by.to_a).to match_array([a3, a2]) 66 | end 67 | 68 | it 'can not find all nodes having a relationship to another node if there are non' do 69 | expect(ClazzB.query_as(:b).match('(b)<-[:knows]-(r)').pluck(:r)).to eq([]) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/e2e/reflections_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'reflections' do 2 | before do 3 | stub_node_class('MyClass') do 4 | has_many :in, :in_things, model_class: self, type: 'things' 5 | has_many :out, :out_things, model_class: self, type: 'things' 6 | 7 | has_many :in, :in_things_string, model_class: self.to_s, type: 'things' 8 | has_many :out, :things_with_rel_class, model_class: self, rel_class: :RelClass 9 | has_many :out, :string_rel_class, model_class: self, rel_class: :RelClass 10 | has_one :out, :one_thing, model_class: self, type: 'one_thing' 11 | end 12 | 13 | stub_relationship_class('RelClass') do 14 | from_class :any 15 | to_class :any 16 | type 'things' 17 | end 18 | end 19 | 20 | let(:clazz) { MyClass } 21 | let(:rel_clazz) { RelClass } 22 | 23 | it 'responds to :reflections' do 24 | expect { clazz.reflections }.not_to raise_error 25 | end 26 | 27 | it 'responds with a hash' do 28 | expect(clazz.reflections).to be_a(Hash) 29 | end 30 | 31 | it 'contains a key for each association' do 32 | expect(clazz.reflections).to have_key(:in_things) 33 | expect(clazz.reflections).to have_key(:out_things) 34 | end 35 | 36 | it 'returns information about a given association' do 37 | reflection = clazz.reflect_on_association(:in_things) 38 | expect(reflection).to be_a(ActiveGraph::Node::Reflection::AssociationReflection) 39 | expect(reflection.klass).to eq clazz 40 | expect(reflection.class_name).to eq clazz.name 41 | expect(reflection.type).to eq :things 42 | expect(reflection.collection?).to be_truthy 43 | expect(reflection.validate?).to be_truthy 44 | 45 | reflection = clazz.reflect_on_association(:one_thing) 46 | expect(reflection.collection?).to be_falsey 47 | end 48 | 49 | it 'returns a reflection for each association' do 50 | expect(clazz.reflect_on_all_associations.count).to eq 6 51 | end 52 | 53 | it 'recognizes rel classes' do 54 | reflection = clazz.reflect_on_association(:things_with_rel_class) 55 | expect(reflection.rel_klass).to eq rel_clazz 56 | expect(reflection.rel_class_name).to eq rel_clazz.name 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/e2e/validation_association_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Validations do 2 | before(:each) do 3 | stub_node_class('Comment') 4 | 5 | stub_node_class('Post') do 6 | property :name, type: String 7 | has_many :out, :comments, type: :COMMENT 8 | 9 | validates :name, presence: true 10 | validates :comments, presence: true 11 | end 12 | end 13 | 14 | context 'validating presence' do 15 | it 'new object should not be valid without comments' do 16 | expect(Post.new({})).not_to be_valid 17 | end 18 | 19 | it 'should not be valid without comments' do 20 | expect(Post.create).not_to be_valid 21 | end 22 | 23 | it 'should be valid with comments' do 24 | expect(Post.new(name: 'abc', comments: [Comment.create])).to be_valid 25 | end 26 | end 27 | 28 | # The below spec pass on active_record as is 29 | context 'active_record behaviour' do 30 | let(:post) { Post.create!(name: 'abc', comments: [Comment.create]) } 31 | 32 | it 'comment= ignores validation' do 33 | post.comments = [] 34 | expect(post.comments.size).to eq(0) 35 | expect(post.comments.count).to eq(0) 36 | expect(Post.find(post.id).comments.count).to eq(0) 37 | end 38 | 39 | it 'update respects validation' do 40 | expect(post.update(comments: [])).to be false 41 | expect(post.comments.size).to eq(0) 42 | expect(post.comments.count).to eq(1) 43 | expect(Post.find(post.id).comments.count).to eq(1) 44 | end 45 | 46 | it 'comments= saves invalid object' do 47 | expect(Post.find(post.id)).to be_valid 48 | post.comments = [] 49 | expect(Post.find(post.id)).to be_invalid 50 | end 51 | 52 | it 'update does not save invalid object' do 53 | expect(Post.find(post.id)).to be_valid 54 | expect(post.update(comments: [])).to be false 55 | expect(Post.find(post.id)).to be_valid 56 | end 57 | 58 | it 'should not save valid association if property is invalid' do 59 | expect(post.update(name: nil, comments: [Comment.create, Comment.create])).to be false 60 | expect(Post.find(post.id).comments.count).to eq(1) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/unit/shared/type_converters_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | describe TypeConverters do 3 | subject(:model) { properties_class.new } 4 | 5 | let :properties_class do 6 | Class.new do 7 | include ActiveGraph::Shared::TypeConverters 8 | end 9 | end 10 | 11 | describe '#typecast_attribute' do 12 | it 'raises an ArgumentError when a nil type is given' do 13 | expect { model.typecast_attribute(nil, 'foo') }.to raise_error(ArgumentError, /A typecaster must be given/) 14 | end 15 | 16 | it 'raises an ArgumentError when the given typecaster argument does not respond to #call' do 17 | expect { model.typecast_attribute(Object.new, 'foo') }.to raise_error(ArgumentError, /A typecaster must be given/) 18 | end 19 | 20 | it 'returns the original value when the value is nil' do 21 | expect(properties_class.new.typecast_attribute(double(to_ruby: 1), nil)).to be_nil 22 | end 23 | end 24 | 25 | describe '#typecaster_for' do 26 | 27 | it 'returns BooleanTypecaster for Boolean' do 28 | expect(model.typecaster_for(ActiveGraph::Shared::Boolean)).to eq TypeConverters::BooleanConverter 29 | end 30 | 31 | it 'returns DateTypecaster for Date' do 32 | expect(model.typecaster_for(Date)).to eq TypeConverters::DateConverter 33 | end 34 | 35 | it 'returns DateTimeTypecaster for DateTime' do 36 | expect(model.typecaster_for(DateTime)).to eq TypeConverters::DateTimeConverter 37 | end 38 | 39 | it 'returns FloatTypecaster for Float' do 40 | expect(model.typecaster_for(Float)).to eq TypeConverters::FloatConverter 41 | end 42 | 43 | it 'returns IntegerTypecaster for Integer' do 44 | expect(model.typecaster_for(Integer)).to eq TypeConverters::IntegerConverter 45 | end 46 | 47 | it 'returns StringTypecaster for String' do 48 | expect(model.typecaster_for(String)).to eq TypeConverters::StringConverter 49 | end 50 | 51 | it 'returns ObjectTypecaster for Object' do 52 | expect(model.typecaster_for(Object)).to eq TypeConverters::ObjectConverter 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/active_graph/shared/mass_assignment.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | # MassAssignment allows you to bulk set and update attributes 3 | # 4 | # Including MassAssignment into your model gives it a set of mass assignment 5 | # methods, similar to those found in ActiveRecord. 6 | # 7 | # @example Usage 8 | # class Person 9 | # include ActiveGraph::Shared::MassAssignment 10 | # end 11 | # 12 | # Originally part of ActiveAttr, https://github.com/cgriego/active_attr 13 | module MassAssignment 14 | extend ActiveSupport::Concern 15 | # Mass update a model's attributes 16 | # 17 | # @example Assigning a hash 18 | # person.assign_attributes(:first_name => "Chris", :last_name => "Griego") 19 | # person.first_name #=> "Chris" 20 | # person.last_name #=> "Griego" 21 | # 22 | # @param [Hash{#to_s => Object}, #each] new_attributes Attributes used to 23 | # populate the model 24 | def assign_attributes(new_attributes = nil) 25 | return unless new_attributes.present? 26 | new_attributes.each do |name, value| 27 | writer = :"#{name}=" 28 | if respond_to?(writer) 29 | send(writer, value) 30 | else 31 | add_undeclared_property(name, value) 32 | end 33 | end 34 | end 35 | 36 | def add_undeclared_property(_, _); end 37 | 38 | # Mass update a model's attributes 39 | # 40 | # @example Assigning a hash 41 | # person.attributes = { :first_name => "Chris", :last_name => "Griego" } 42 | # person.first_name #=> "Chris" 43 | # person.last_name #=> "Griego" 44 | # 45 | # @param (see #assign_attributes) 46 | def attributes=(new_attributes) 47 | assign_attributes(new_attributes) 48 | end 49 | 50 | # Initialize a model with a set of attributes 51 | # 52 | # @example Initializing with a hash 53 | # person = Person.new(:first_name => "Chris", :last_name => "Griego") 54 | # person.first_name #=> "Chris" 55 | # person.last_name #=> "Griego" 56 | # 57 | # @param (see #assign_attributes) 58 | def initialize(attributes = nil) 59 | assign_attributes(attributes) 60 | super() 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/active_graph/node/orm_adapter.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | class OrmAdapter < ::OrmAdapter::Base 4 | module ClassMethods 5 | include ActiveModel::Callbacks 6 | end 7 | 8 | def column_names 9 | klass._decl_props.keys 10 | end 11 | 12 | def i18n_scope 13 | :neo4j 14 | end 15 | 16 | # Get an instance by id of the model 17 | def get!(id) 18 | klass.find(wrap_key(id)).tap do |node| 19 | fail 'No record found' if node.nil? 20 | end 21 | end 22 | 23 | # Get an instance by id of the model 24 | def get(id) 25 | klass.find_by(klass.id_property_name => wrap_key(id)) 26 | end 27 | 28 | # Find the first instance matching conditions 29 | def find_first(options = {}) 30 | conditions, order = extract_conditions!(options) 31 | extract_id!(conditions) 32 | order = hasherize_order(order) 33 | 34 | result = klass.where(conditions) 35 | result = result.order(order) unless order.empty? 36 | result.first 37 | end 38 | 39 | # Find all models matching conditions 40 | def find_all(options = {}) 41 | conditions, order, limit, offset = extract_conditions!(options) 42 | extract_id!(conditions) 43 | order = hasherize_order(order) 44 | 45 | result = klass.where(conditions) 46 | result = result.order(order) unless order.empty? 47 | result = result.skip(offset) if offset 48 | result = result.limit(limit) if limit 49 | result.to_a 50 | end 51 | 52 | # Create a model using attributes 53 | def create!(attributes = {}) 54 | klass.create!(attributes) 55 | end 56 | 57 | # @see OrmAdapter::Base#destroy 58 | def destroy(object) 59 | object.destroy && true if valid_object?(object) 60 | end 61 | 62 | private 63 | 64 | def hasherize_order(order) 65 | (order || []).map { |clause| Hash[*clause] } 66 | end 67 | 68 | def extract_id!(conditions) 69 | id = conditions.delete(:id) 70 | return if not id 71 | 72 | conditions[klass.id_property_name.to_sym] = id 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/active_graph/node/property.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node 2 | module Property 3 | extend ActiveSupport::Concern 4 | include ActiveGraph::Shared::Property 5 | 6 | def initialize(attributes = nil) 7 | super(attributes) 8 | @attributes ||= ActiveGraph::AttributeSet.new(self.class.attributes_nil_hash, self.class.attributes.keys) 9 | end 10 | 11 | module ClassMethods 12 | # Extracts keys from attributes hash which are associations of the model 13 | # TODO: Validate separately that relationships are getting the right values? Perhaps also store the values and persist relationships on save? 14 | def extract_association_attributes!(attributes) 15 | return unless contains_association?(attributes) 16 | attributes.each_with_object({}) do |(key, _), result| 17 | result[key] = attributes.delete(key) if self.association_key?(key) 18 | end 19 | end 20 | 21 | def association_key?(key) 22 | association_method_keys.include?(key.to_sym) 23 | end 24 | 25 | private 26 | 27 | def contains_association?(attributes) 28 | return false unless attributes 29 | attributes.each_key { |k| return true if association_key?(k) } 30 | false 31 | end 32 | 33 | # All keys which could be association setter methods (including _id/_ids) 34 | def association_method_keys 35 | @association_method_keys ||= 36 | associations_keys.map(&:to_sym) + 37 | associations.values.map do |association| 38 | if association.type == :has_one 39 | "#{association.name}_id" 40 | elsif association.type == :has_many 41 | "#{association.name.to_s.singularize}_ids" 42 | end.to_sym 43 | end 44 | end 45 | end 46 | 47 | private 48 | 49 | def inspect_attributes 50 | id_property_name = self.class.id_property_name.to_s 51 | 52 | attribute_pairs = attributes.except(id_property_name).sort.map do |key, value| 53 | [key, (value.is_a?(String) && value.size > 100) ? value.dup[0..100] : value] 54 | end 55 | 56 | attribute_pairs.unshift([id_property_name, self.send(id_property_name)]) 57 | attribute_pairs 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/e2e/basic_model_spec.rb: -------------------------------------------------------------------------------- 1 | require 'shared_examples/new_model' 2 | require 'shared_examples/loadable_model' 3 | require 'shared_examples/saveable_model' 4 | require 'shared_examples/creatable_model' 5 | require 'shared_examples/destroyable_model' 6 | 7 | describe 'BasicModel' do 8 | before(:each) do 9 | clear_model_memory_caches 10 | 11 | stub_node_class('BasicModel') do 12 | property :name 13 | property :a 14 | property :b 15 | 16 | before_destroy :before_destroy_callback 17 | def before_destroy_callback 18 | self.class.before_destroy_callback_calls += 1 19 | end 20 | 21 | class << self 22 | attr_accessor :before_destroy_callback_calls 23 | end 24 | 25 | self.before_destroy_callback_calls = 0 26 | end 27 | end 28 | 29 | subject { BasicModel.new } 30 | 31 | it_should_behave_like 'new model' 32 | it_should_behave_like 'loadable model' 33 | it_should_behave_like 'saveable model' 34 | it_should_behave_like 'creatable model' 35 | it_should_behave_like 'destroyable model' 36 | it_should_behave_like 'updatable model' 37 | 38 | it 'has a label' do 39 | expect(subject.class.create!.labels).to eq([:BasicModel]) 40 | end 41 | 42 | context "when there's lots of them" do 43 | before(:each) do 44 | subject.class.delete_all 45 | 3.times { subject.class.create! } 46 | end 47 | 48 | it 'should be possible to #count' do 49 | expect(subject.class.count).to eq(3) 50 | end 51 | 52 | it 'should be possible to #delete_all' do 53 | expect_any_instance_of(subject.class).not_to receive(:before_destroy_callback) 54 | 55 | expect(subject.class.count).to eq 3 56 | expect(subject.class.before_destroy_callback_calls).to eq 0 57 | subject.class.delete_all 58 | expect(subject.class.count).to eq 0 59 | expect(subject.class.before_destroy_callback_calls).to eq 0 60 | end 61 | 62 | it 'should be possible to #destroy_all' do 63 | expect(subject.class.count).to eq 3 64 | expect(subject.class.before_destroy_callback_calls).to eq 0 65 | subject.class.destroy_all 66 | expect(subject.class.count).to eq 0 67 | expect(subject.class.before_destroy_callback_calls).to eq 3 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/active_graph/shared/callbacks.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Shared 3 | module Callbacks #:nodoc: 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods 7 | include ActiveModel::Callbacks 8 | end 9 | 10 | included do 11 | include ActiveModel::Validations::Callbacks 12 | # after_find is triggered by the `find` method defined in lib/active_graph/node/id_property.rb 13 | define_model_callbacks :initialize, :find, only: :after 14 | define_model_callbacks :create_commit, :update_commit, :destroy_commit, only: :after 15 | define_model_callbacks :save, :create, :update, :destroy, :touch 16 | end 17 | 18 | def initialize(args = nil) 19 | run_callbacks(:initialize) { super } 20 | end 21 | 22 | def destroy #:nodoc: 23 | ActiveGraph::Base.validating_transaction do |tx| 24 | tx.after_commit { run_callbacks(:destroy_commit) {} } 25 | run_callbacks(:destroy) { super } 26 | end 27 | rescue 28 | @_deleted = false 29 | @attributes = @attributes.dup 30 | raise 31 | end 32 | 33 | def touch #:nodoc: 34 | run_callbacks(:touch) { super } 35 | end 36 | 37 | # Allows you to perform a callback if a condition is not satisfied. 38 | # @param [Symbol] kind The callback type to execute unless the guard is true 39 | # @param [TrueClass,FalseClass] guard When this value is true, the block is yielded without executing callbacks. 40 | def conditional_callback(kind, guard) 41 | return yield if guard 42 | run_callbacks(kind) { yield } 43 | end 44 | 45 | private 46 | 47 | def create_or_update #:nodoc: 48 | run_callbacks(:save) { super } 49 | end 50 | 51 | def create_model #:nodoc: 52 | ActiveGraph::Base.transaction do |tx| 53 | tx.after_commit { run_callbacks(:create_commit) {} } 54 | run_callbacks(:create) { super } 55 | end 56 | end 57 | 58 | def update_model(*) #:nodoc: 59 | ActiveGraph::Base.transaction do |tx| 60 | tx.after_commit { run_callbacks(:update_commit) {} } 61 | run_callbacks(:update) { super } 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/e2e/generators_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/active_graph/model/model_generator' 3 | require 'rails/generators/active_graph/migration/migration_generator' 4 | require 'rails/generators/active_graph/upgrade_v8/upgrade_v8_generator' 5 | 6 | describe 'Generators' do 7 | around do |example| 8 | Timecop.freeze(Time.parse('1990-12-10 00:00:00 -0000')) { example.run } 9 | end 10 | 11 | describe ActiveGraph::Generators::ModelGenerator do 12 | it 'has a `source_root`' do 13 | expect(described_class.source_root).to include('rails/generators/active_graph/model/templates') 14 | end 15 | 16 | it 'creates a model and a migration file' do 17 | expect_any_instance_of(described_class).to receive(:template).with('model.erb', 'app/models/some.rb') 18 | expect_any_instance_of(described_class).to receive(:template).with('migration.erb', 'db/neo4j/migrate/19901210000000_create_some.rb') 19 | described_class.new(['some']).create_model_file 20 | end 21 | end 22 | 23 | describe ActiveGraph::Generators::MigrationGenerator do 24 | it 'has a `source_root`' do 25 | expect(described_class.source_root).to include('rails/generators/active_graph/migration/templates') 26 | end 27 | 28 | it 'creates a migration file' do 29 | expect_any_instance_of(described_class).to receive(:template).with('migration.erb', 'db/neo4j/migrate/19901210000000_some.rb') 30 | described_class.new(['some']).create_migration_file 31 | end 32 | end 33 | 34 | describe ActiveGraph::Generators::UpgradeV8Generator do 35 | before do 36 | app = double 37 | allow(app).to receive(:eager_load!) do 38 | stub_node_class('Person') do 39 | property :name, index: :exact 40 | end 41 | end 42 | allow(Rails).to receive(:application).and_return(app) 43 | end 44 | 45 | it 'has a `source_root`' do 46 | expect(described_class.source_root).to include('rails/generators/active_graph/upgrade_v8/templates') 47 | end 48 | 49 | it 'creates a migration file' do 50 | expect_any_instance_of(described_class).to receive(:template).with('migration.erb', 'db/neo4j/migrate/19901210000000_upgrate_to_v8.rb') 51 | described_class.new.create_upgrade_v8_file 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/active_graph/node/validations.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Node 3 | # This mixin replace the original save method and performs validation before the save. 4 | module Validations 5 | extend ActiveSupport::Concern 6 | include ActiveGraph::Shared::Validations 7 | 8 | # @return [Boolean] true if valid 9 | def valid?(context = nil) 10 | context ||= (new_record? ? :create : :update) 11 | super(context) 12 | errors.empty? 13 | end 14 | 15 | module ClassMethods 16 | def validates_uniqueness_of(*attr_names) 17 | validates_with UniquenessValidator, _merge_attributes(attr_names) 18 | end 19 | end 20 | 21 | class UniquenessValidator < ::ActiveModel::EachValidator 22 | def initialize(options) 23 | super(options.reverse_merge(case_sensitive: true)) 24 | end 25 | 26 | def validate_each(record, attribute, value) 27 | return unless found(record, attribute, value).exists? 28 | 29 | record.errors.add(attribute, :taken, **options.except(:case_sensitive, :scope).merge(value: value)) 30 | end 31 | 32 | def found(record, attribute, value) 33 | scopes, attributes = Array(options[:scope] || []).partition { |s| s.is_a?(Proc) } 34 | conditions = scope_conditions(record, attributes) 35 | 36 | # TODO: Added as find(:name => nil) throws error 37 | value = '' if value.nil? 38 | 39 | conditions[attribute] = options[:case_sensitive] ? value : /#{Regexp.escape(value.to_s)}/i 40 | 41 | found = if scopes.empty? 42 | record.class.as(:result) 43 | else 44 | scopes.reduce(record) { |proxy, scope| proxy.instance_eval(&scope) } 45 | end.where(conditions) 46 | found = found.where_not(neo_id: record.neo_id) if record._persisted_obj 47 | found 48 | end 49 | 50 | def message(instance) 51 | super || 'has already been taken' 52 | end 53 | 54 | def scope_conditions(instance, attributes) 55 | attributes.inject({}) do |conditions, key| 56 | conditions.merge(key => instance[key]) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/active_graph/shared/rel_type_converters.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | # This module controls changes to relationship type based on ActiveGraph::Config.transform_rel_type. 3 | # It's used whenever a rel type is automatically determined based on Relationship model name or 4 | # association type. 5 | module RelTypeConverters 6 | def decorated_rel_type(type) 7 | @decorated_rel_type ||= ActiveGraph::Shared::RelTypeConverters.decorated_rel_type(type) 8 | end 9 | 10 | class << self 11 | # Determines how relationship types should look when inferred based on association or Relationship model name. 12 | # With the exception of `:none`, all options will call `underscore`, so `ThisClass` becomes `this_class`, with capitalization 13 | # determined by the specific option passed. 14 | # Valid options: 15 | # * :upcase - `:this_class`, `ThisClass`, `thiS_claSs` (if you don't like yourself) becomes `THIS_CLASS` 16 | # * :downcase - same as above, only... downcased. 17 | # * :legacy - downcases and prepends `#`, so ThisClass becomes `#this_class` 18 | # * :none - uses the string version of whatever is passed with no modifications 19 | def rel_transformer 20 | @rel_transformer ||= ActiveGraph::Config[:transform_rel_type].nil? ? :upcase : ActiveGraph::Config[:transform_rel_type] 21 | end 22 | 23 | # @param [String,Symbol] type The raw string or symbol to be used as the basis of the relationship type 24 | # @return [String] A string that conforms to the set rel type conversion setting. 25 | def decorated_rel_type(type) 26 | type = type.to_s 27 | decorated_type = case rel_transformer 28 | when :upcase 29 | type.underscore.upcase 30 | when :downcase 31 | type.underscore.downcase 32 | when :legacy 33 | "##{type.underscore.downcase}" 34 | when :none 35 | type 36 | else 37 | type.underscore.upcase 38 | end 39 | decorated_type.tap { |s| s.gsub!('/', '::') if type.include?('::') } 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /activegraph.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 3 | 4 | require 'active_graph/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'activegraph' 8 | s.version = ActiveGraph::VERSION 9 | 10 | s.required_ruby_version = '>= 2.6' 11 | 12 | s.authors = 'Andreas Ronge, Brian Underwood, Chris Grigg, Heinrich Klobuczek' 13 | s.email = 'andreas.ronge@gmail.com, public@brian-underwood.codes, chris@subvertallmedia.com, heinrich@mail.com' 14 | s.homepage = 'https://github.com/neo4jrb/activegraph/' 15 | s.summary = 'A graph database for Ruby' 16 | s.license = 'MIT' 17 | s.description = <<-DESCRIPTION 18 | A Neo4j OGM (Object-Graph-Mapper) for Ruby heavily inspired by ActiveRecord. 19 | DESCRIPTION 20 | 21 | s.require_path = 'lib' 22 | s.files = Dir.glob('{bin,lib,config}/**/*') + %w(README.md CHANGELOG.md CONTRIBUTORS Gemfile activegraph.gemspec) 23 | s.executables = [] 24 | s.extra_rdoc_files = %w( README.md ) 25 | s.rdoc_options = ['--quiet', '--title', 'Neo4j.rb', '--line-numbers', '--main', 'README.rdoc', '--inline-source'] 26 | s.metadata = { 27 | 'homepage_uri' => 'http://neo4jrb.io/', 28 | 'changelog_uri' => 'https://github.com/neo4jrb/activegraph/blob/master/CHANGELOG.md', 29 | 'source_code_uri' => 'https://github.com/neo4jrb/activegraph/', 30 | 'bug_tracker_uri' => 'https://github.com/neo4jrb/activegraph/issues' 31 | } 32 | 33 | s.add_dependency('activemodel', '>= 7') 34 | s.add_dependency('i18n', '!= 1.8.8') # https://github.com/jruby/jruby/issues/6547 35 | s.add_dependency('neo4j-ruby-driver', '>= 4.4.1', '< 5') 36 | s.add_dependency('orm_adapter', '>= 0.5.0') 37 | s.add_dependency('sorted_set') 38 | s.add_development_dependency('guard') 39 | s.add_development_dependency('guard-rspec') 40 | s.add_development_dependency('guard-rubocop') 41 | s.add_development_dependency('neo4j-rake_tasks', '>= 0.3.0') 42 | s.add_development_dependency('os') 43 | s.add_development_dependency('pry') 44 | s.add_development_dependency('railties', '>= 7') 45 | s.add_development_dependency('rake') 46 | s.add_development_dependency('rubocop', '>= 0.56.0') 47 | s.add_development_dependency('yard') 48 | s.add_development_dependency('dryspec') 49 | s.add_development_dependency('rspec', '>= 3.10') 50 | end 51 | -------------------------------------------------------------------------------- /spec/unit/node/validation_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Validations do 2 | let(:node) { double('a persisted node') } 3 | before(:each) { allow_any_instance_of(clazz).to receive(:_persisted_obj).and_return(nil) } 4 | 5 | let(:clazz) do 6 | Class.new do 7 | include ActiveGraph::Shared 8 | include ActiveGraph::Shared::Identity 9 | include ActiveGraph::Node::Query 10 | include ActiveGraph::Node::Persistence 11 | include ActiveGraph::Node::Unpersisted 12 | include ActiveGraph::Node::HasN 13 | include ActiveGraph::Node::Property 14 | include ActiveGraph::Node::Validations 15 | 16 | property :name 17 | property :age, type: Integer 18 | 19 | validates :name, presence: true 20 | 21 | def self.mapped_label_names 22 | :MyClass 23 | end 24 | 25 | def self.model_name 26 | ActiveModel::Name.new(self, nil, 'MyClass') 27 | end 28 | 29 | def self.fetch_upstream_primitive(_attr) 30 | nil 31 | end 32 | end 33 | end 34 | 35 | describe 'save' do 36 | context 'when valid' do 37 | it 'creates a new node if not persisted before' do 38 | o = clazz.new(name: 'kalle', age: '42') 39 | allow(o).to receive(:_persisted_obj).and_return(nil) 40 | allow(o).to receive(:serialized_properties).and_return({}) 41 | o.serialized_properties 42 | allow(clazz).to receive(:default_property_values).and_return({}) 43 | expect(node).to receive(:properties).and_return(name: 'kalle2', age: '43') 44 | expect(o).to receive(:_create_node).with({ name: 'kalle', age: 42 }).and_return(node) 45 | expect(o).to receive(:init_on_load).with(node, { age: '43', name: 'kalle2' }) 46 | allow(Object).to receive(:serialized_properties_keys).and_return([]) 47 | expect(o.save).to be true 48 | end 49 | end 50 | 51 | context 'when not valid' do 52 | it 'does not create a new node' do 53 | o = clazz.new(age: '42') 54 | allow(o).to receive(:_persisted_obj).and_return(nil) 55 | expect(o.save).to be false 56 | end 57 | 58 | it 'does not update a node' do 59 | o = clazz.new 60 | o.age = '42' 61 | allow(o).to receive(:_persisted_obj).and_return(node) 62 | expect(o.save).to be false 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/unit/shared/filtered_hash_spec.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | describe FilteredHash do 3 | let(:base) { {first: :foo, second: :bar, third: :baz, fourth: :buzz} } 4 | let(:instructions) { :all } 5 | let(:filtered_props) { described_class.new(base, instructions) } 6 | 7 | describe '#initialize' do 8 | it 'takes a hash of properties and an instructions argument' do 9 | expect { filtered_props }.not_to raise_error 10 | end 11 | end 12 | 13 | describe 'accessors' do 14 | subject { described_class.new(base, instructions) } 15 | 16 | it { expect(subject.base).to eq base } 17 | it { expect(subject.instructions).to eq instructions } 18 | end 19 | 20 | describe 'instructions' do 21 | describe 'symbols' do 22 | it 'raise unless :all or :none' do 23 | expect { FilteredHash.new(base, :all) }.not_to raise_error 24 | expect { FilteredHash.new(base, :none) }.not_to raise_error 25 | expect { FilteredHash.new(base, :foo) }.to raise_error FilteredHash::InvalidHashFilterType 26 | end 27 | 28 | describe 'filtering' do 29 | context ':all' do 30 | let(:instructions) { :all } 31 | it 'returns [original_hash, empty_hash]' do 32 | expect(filtered_props.filtered_base).to eq([base, {}]) 33 | end 34 | end 35 | 36 | context ':none' do 37 | let(:instructions) { :none } 38 | it 'returns [empty_hash, original_hash]' do 39 | expect(filtered_props.filtered_base).to eq([{}, base]) 40 | end 41 | end 42 | end 43 | end 44 | 45 | describe 'hash' do 46 | it 'raises unless first key is :on' do 47 | expect { FilteredHash.new(base, on: :foo) }.not_to raise_error 48 | expect { FilteredHash.new(base, foo: :foo) }.to raise_error FilteredHash::InvalidHashFilterType 49 | end 50 | 51 | describe 'filtering' do 52 | context 'on:' do 53 | let(:instructions) { {on: [:second, :fourth]} } 54 | it 'returns [hash with keys specified, hash with remaining key' do 55 | expect(filtered_props.filtered_base).to eq([{second: :bar, fourth: :buzz}, {first: :foo, third: :baz}]) 56 | end 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/helpers/relationships.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | module Helpers 4 | module Relationships 5 | extend ActiveSupport::Concern 6 | 7 | DEFAULT_MAX_PER_BATCH = 1000 8 | 9 | def change_relations_style(relationships, old_style, new_style, params = {}) 10 | relationships.each do |rel| 11 | relabel_relation(relationship_style(rel, old_style), relationship_style(rel, new_style), params) 12 | end 13 | end 14 | 15 | def relabel_relation(old_name, new_name, params = {}) 16 | relation_query = match_relation(old_name, params) 17 | 18 | max_per_batch = (ENV['MAX_PER_BATCH'] || DEFAULT_MAX_PER_BATCH).to_i 19 | 20 | count = count_relations(relation_query) 21 | output "Indexing #{count} #{old_name}s into #{new_name}..." 22 | while count > 0 23 | relation_query.create("(a)-[r2:`#{new_name}`]->(b)").set('r2 = r').with(:r).limit(max_per_batch).delete(:r).exec 24 | count = count_relations(relation_query) 25 | output "... #{count} #{old_name}'s left to go.." if count > 0 26 | end 27 | end 28 | 29 | private 30 | 31 | def match_relation(label, params = {}) 32 | from = params[:from] ? "(a:`#{params[:from]}`)" : '(a)' 33 | to = params[:to] ? "(b:`#{params[:to]}`)" : '(b)' 34 | relation = arrow_cypher(label, params[:direction]) 35 | 36 | query.match("#{from}#{relation}#{to}") 37 | end 38 | 39 | def arrow_cypher(label, direction) 40 | case direction 41 | when :in 42 | "<-[r:`#{label}`]-" 43 | when :both 44 | "<-[r:`#{label}`]->" 45 | else 46 | "-[r:`#{label}`]->" 47 | end 48 | end 49 | 50 | def count_relations(query) 51 | query.pluck('COUNT(r)').first 52 | end 53 | 54 | def relationship_style(relationship, format) 55 | case format.to_s 56 | when 'lower_hashtag' then "##{relationship.downcase}" 57 | when 'lower' then relationship.downcase 58 | when 'upper' then relationship.upcase 59 | else 60 | fail("Invalid relationship type style `#{format}`.") 61 | end 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/active_graph/node/has_n/association/rel_factory.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Node::HasN 2 | class Association 3 | class RelFactory 4 | [:start_object, :other_node_or_nodes, :properties, :association].tap do |accessors| 5 | attr_reader(*accessors) 6 | private(*accessors) 7 | end 8 | 9 | def self.create(start_object, other_node_or_nodes, properties, association) 10 | factory = new(start_object, other_node_or_nodes, properties, association) 11 | factory._create_relationship 12 | end 13 | 14 | def _create_relationship 15 | creator = association.relationship_class ? :rel_class : :factory 16 | send(:"_create_relationship_with_#{creator}") 17 | end 18 | 19 | private 20 | 21 | def initialize(start_object, other_node_or_nodes, properties, association) 22 | @start_object = start_object 23 | @other_node_or_nodes = other_node_or_nodes 24 | @properties = properties 25 | @association = association 26 | end 27 | 28 | def _create_relationship_with_rel_class 29 | Array(other_node_or_nodes).each do |other_node| 30 | node_props = _nodes_for_create(other_node, :from_node, :to_node) 31 | association.relationship_class.create!(properties.merge(node_props)) 32 | end 33 | end 34 | 35 | def _create_relationship_with_factory 36 | Array(other_node_or_nodes).each do |other_node| 37 | wrapper = _rel_wrapper(properties) 38 | base = _match_query(other_node, wrapper) 39 | factory = ActiveGraph::Shared::RelQueryFactory.new(wrapper, wrapper.rel_identifier) 40 | factory.base_query = base 41 | factory.query.exec 42 | end 43 | end 44 | 45 | def _match_query(other_node, wrapper) 46 | nodes = _nodes_for_create(other_node, wrapper.from_node_identifier, wrapper.to_node_identifier) 47 | ActiveGraph::Base.new_query.match_nodes(nodes) 48 | end 49 | 50 | def _nodes_for_create(other_node, from_node_id, to_node_id) 51 | nodes = [@start_object, other_node] 52 | nodes.reverse! if association.direction == :in 53 | {from_node_id => nodes[0], to_node_id => nodes[1]} 54 | end 55 | 56 | def _rel_wrapper(properties) 57 | ActiveGraph::Node::HasN::Association::RelWrapper.new(association, properties) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/e2e/wrapped_transactions_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'wrapped nodes in transactions' do 2 | before(:each) do 3 | clear_model_memory_caches 4 | 5 | stub_node_class('Student') do 6 | property :name 7 | 8 | has_many :out, :teachers, model_class: 'Teacher', rel_class: 'StudentTeacher' 9 | end 10 | 11 | stub_node_class('Teacher') do 12 | property :name 13 | has_many :in, :students, model_class: 'Student', rel_class: 'StudentTeacher' 14 | end 15 | 16 | stub_relationship_class('StudentTeacher') do 17 | from_class :Student 18 | to_class :Teacher 19 | type 'teacher' 20 | property :appreciation, type: Integer 21 | end 22 | end 23 | 24 | before(:each) do 25 | Student.delete_all 26 | Teacher.delete_all 27 | 28 | Student.create(name: 'John') 29 | Teacher.create(name: 'Mr Jones') 30 | ActiveGraph::Base.transaction do 31 | @john = Student.first 32 | @jones = Teacher.first 33 | end 34 | end 35 | 36 | it 'can load a node within a transaction' do 37 | expect(@john).to be_a(Student) 38 | expect(@john.name).to eq 'John' 39 | expect(@john.id).not_to be_nil 40 | end 41 | 42 | it 'returns its :labels' do 43 | expect(@john.neo_id).not_to be_nil 44 | expect(@john.labels).to eq [Student.name.to_sym] 45 | end 46 | 47 | it 'responds positively to exist?' do 48 | expect(@john.exist?).to be_truthy 49 | end 50 | 51 | describe 'relationships' do 52 | let!(:rel) { StudentTeacher.create(from_node: @john, to_node: @jones, appreciation: 9000) } 53 | 54 | it 'allows the creation of rels using transaction-loaded nodes' do 55 | expect(rel.persisted?).to be_truthy 56 | expect(rel.appreciation).to eq 9000 57 | end 58 | 59 | it 'will load rels within a tranaction' do 60 | retrieved_rel = ActiveGraph::Base.transaction do 61 | @john.teachers.each_rel do |r| 62 | expect(r).to be_a(StudentTeacher) 63 | end 64 | end 65 | expect(retrieved_rel.first).to be_a(StudentTeacher) 66 | end 67 | 68 | it 'does not create an additional relationship after load then save' do 69 | starting_count = @john.teachers.rels.count 70 | ActiveGraph::Base.transaction do 71 | @john.teachers.each_rel do |r| 72 | r.appreciation = 9001 73 | r.save 74 | end 75 | end 76 | @john.reload 77 | expect(@john.teachers.rels.count).to eq starting_count 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/rails/generators/active_graph/model/model_generator.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../migration_helper' 2 | require_relative '../../source_path_helper' 3 | 4 | class ActiveGraph::Generators::ModelGenerator < Rails::Generators::NamedBase #:nodoc: 5 | include ActiveGraph::Generators::SourcePathHelper 6 | include ActiveGraph::Generators::MigrationHelper 7 | 8 | argument :attributes, type: :array, default: [], banner: 'field:type field:type' 9 | 10 | check_class_collision 11 | 12 | class_option :timestamps, type: :boolean 13 | class_option :parent, type: :string, desc: 'The parent class for the generated model' 14 | class_option :indices, type: :array, desc: 'The properties which should be indexed' 15 | class_option :has_one, type: :array, desc: 'A list of has_one relationships' 16 | class_option :has_many, type: :array, desc: 'A list of has_many relationships' 17 | 18 | def create_model_file 19 | template 'model.erb', File.join('app/models', class_path, "#{singular_name}.rb") 20 | migration_template 'migration.erb', 'create_' 21 | end 22 | 23 | protected 24 | 25 | def migration? 26 | false 27 | end 28 | 29 | def timestamps? 30 | options[:timestamps] 31 | end 32 | 33 | # rubocop:disable Naming/PredicateName 34 | def has_many? 35 | options[:has_many] 36 | end 37 | 38 | def has_many_statements 39 | options[:has_many].each_with_object('') do |key, txt| 40 | txt << has_x('has_many', key) 41 | end 42 | end 43 | 44 | def has_one? 45 | options[:has_one] 46 | end 47 | 48 | def has_x(method, key) 49 | to, from = key.split(':') 50 | (from ? "\n #{method}(:#{to}).from(:#{from})\n" : "\n #{method} :#{to}") 51 | end 52 | 53 | def has_one_statements 54 | txt = '' 55 | options[:has_one].each do |key| 56 | txt << has_x('has_one', key) 57 | end 58 | txt 59 | end 60 | # rubocop:enable Naming/PredicateName 61 | 62 | def indices? 63 | options[:indices] 64 | end 65 | 66 | def index_fragment(property) 67 | return if !options[:indices] || !options[:indices].include?(property) 68 | 69 | "index :#{property}" 70 | end 71 | 72 | def parent? 73 | options[:parent] 74 | end 75 | 76 | def timestamp_statements 77 | ' 78 | property :created_at, type: DateTime 79 | # property :created_on, type: Date 80 | 81 | property :updated_at, type: DateTime 82 | # property :updated_on, type: Date 83 | 84 | ' 85 | end 86 | 87 | hook_for :test_framework 88 | end 89 | -------------------------------------------------------------------------------- /spec/shared_examples/after_commit.rb: -------------------------------------------------------------------------------- 1 | shared_context 'after_commit' do |company_variable, options| 2 | before(:each) do 3 | %w(update create destroy).each do |verb| 4 | Company.send(:attr_reader, :"after_#{verb}_commit_called") 5 | end 6 | 7 | Company.after_create_commit { @after_create_commit_called = true } 8 | Company.after_update_commit { @after_update_commit_called = true } 9 | Company.after_destroy_commit { @after_destroy_commit_called = true } 10 | end 11 | 12 | let(:company) { send(company_variable) } 13 | let(:transactions_count) { options[:transactions_count] } 14 | let(:fail_transaction) { options[:fail_transaction] } 15 | 16 | let(:to_or_not_to) { options[:fail_transaction] ? :not_to : :to } 17 | 18 | def wrap_in_transactions(count, &block) 19 | ActiveGraph::Base.transaction(&count == 1 ? block : ->(tx) { wrap_in_transactions(count - 1, &block) }) 20 | end 21 | 22 | it "handles after_create_commit callbacks #{options.inspect}" do 23 | company = Company.new 24 | 25 | if transactions_count.zero? 26 | expect { company.save }.to change do 27 | company.after_create_commit_called 28 | end 29 | else 30 | expect do 31 | wrap_in_transactions(transactions_count) do |tx| 32 | company.save 33 | tx.rollback if fail_transaction 34 | end 35 | end.send(to_or_not_to, change { company.after_create_commit_called }) 36 | end 37 | end 38 | 39 | it 'handles after_update_commit callbacks' do 40 | company 41 | if transactions_count.zero? 42 | expect { company.update(name: 'some') }.to change do 43 | company.after_update_commit_called 44 | end 45 | else 46 | expect do 47 | wrap_in_transactions(transactions_count) do |tx| 48 | company.update(name: 'some') 49 | tx.rollback if fail_transaction 50 | end 51 | end.send(to_or_not_to, change { company.after_update_commit_called }) 52 | end 53 | end 54 | 55 | it 'handles after_destroy_commit callbacks' do 56 | company 57 | if transactions_count.zero? 58 | expect { company.destroy }.to change do 59 | company.after_destroy_commit_called 60 | end 61 | else 62 | expect do 63 | wrap_in_transactions(transactions_count) do |tx| 64 | company.destroy 65 | tx.rollback if fail_transaction 66 | end 67 | end.send(to_or_not_to, change { company.after_destroy_commit_called }) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Neo4j.rb documentation master file, created by 2 | sphinx-quickstart on Mon Mar 9 22:41:19 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Neo4j.rb's documentation! 7 | ==================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | 14 | Introduction 15 | Setup 16 | 17 | UpgradeGuide 18 | 19 | RakeTasks 20 | 21 | Node 22 | Relationship 23 | 24 | Properties 25 | UniqueIDs 26 | 27 | Querying 28 | QueryExamples 29 | 30 | QueryClauseMethods 31 | 32 | Configuration 33 | 34 | Migrations 35 | 36 | Testing 37 | 38 | Contributing 39 | 40 | AdditionalResources 41 | 42 | HelperGems 43 | 44 | api/index 45 | 46 | ActiveGraph (the `activegraph `_ gem) is a `Ruby `_ 47 | Object-Graph-Mapper (OGM) for the `Neo4j `_ graph database. It tries to follow API conventions 48 | established by `ActiveRecord `_ and familiar to most Ruby 49 | developers but with a Neo4j flavor. 50 | 51 | Ruby 52 | (software) A dynamic, open source programming language with a focus on simplicity and productivity. It has an elegant syntax that is natural to read and easy to write. 53 | 54 | Graph Database 55 | (computer science) A graph database stores data in a graph, the most generic of data structures, capable of elegantly representing any kind of data in a highly accessible way. 56 | 57 | Neo4j 58 | (databases) The world's leading graph database 59 | 60 | 61 | If you're already familiar with ActiveRecord, DataMapper, or Mongoid, you'll find the Object Model features you've come to expect from an O*M: 62 | 63 | * Properties 64 | * Indexes / Constraints 65 | * Callbacks 66 | * Validation 67 | * Associations 68 | 69 | Because relationships are first-class citizens in Neo4j, models can be created for both nodes and relationships. 70 | 71 | Additional features include 72 | --------------------------- 73 | 74 | * A chainable `arel `_-inspired query builder 75 | * Transactions 76 | * Migration framework 77 | 78 | Requirements 79 | ------------ 80 | 81 | * Ruby 2.5 + (tested in MRI and JRuby) 82 | * Neo4j 3.4 + 83 | 84 | 85 | Indices and tables 86 | ================== 87 | 88 | * :ref:`genindex` 89 | * :ref:`modindex` 90 | * :ref:`search` 91 | 92 | -------------------------------------------------------------------------------- /lib/active_graph/shared/filtered_hash.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph::Shared 2 | class FilteredHash 3 | class InvalidHashFilterType < ActiveGraph::Error; end 4 | VALID_SYMBOL_INSTRUCTIONS = [:all, :none] 5 | VALID_HASH_INSTRUCTIONS = [:on] 6 | VALID_INSTRUCTIONS_TYPES = [Hash, Symbol] 7 | 8 | attr_reader :base, :instructions, :instructions_type 9 | 10 | def initialize(base, instructions) 11 | @base = base 12 | @instructions = instructions 13 | @instructions_type = instructions.class 14 | validate_instructions!(instructions) 15 | end 16 | 17 | def filtered_base 18 | case instructions 19 | when Symbol 20 | filtered_base_by_symbol 21 | when Hash 22 | filtered_base_by_hash 23 | end 24 | end 25 | 26 | private 27 | 28 | def filtered_base_by_symbol 29 | case instructions 30 | when :all 31 | [base, {}] 32 | when :none 33 | [{}, base] 34 | end 35 | end 36 | 37 | def filtered_base_by_hash 38 | behavior_key = instructions.keys.first 39 | filter_keys = keys_array(behavior_key) 40 | [filter(filter_keys, :with), filter(filter_keys, :without)] 41 | end 42 | 43 | def key?(filter_keys, key) 44 | filter_keys.include?(key) 45 | end 46 | 47 | def filter(filter_keys, key) 48 | filtering = key == :with 49 | base.select { |k, _v| key?(filter_keys, k) == filtering } 50 | end 51 | 52 | def keys_array(key) 53 | instructions[key].is_a?(Array) ? instructions[key] : [instructions[key]] 54 | end 55 | 56 | def validate_instructions!(instructions) 57 | fail InvalidHashFilterType, "Filtering instructions #{instructions} are invalid" unless VALID_INSTRUCTIONS_TYPES.include?(instructions.class) 58 | clazz = instructions_type.name.downcase 59 | return if send(:"valid_#{clazz}_instructions?", instructions) 60 | fail InvalidHashFilterType, "Invalid instructions #{instructions}, valid options for #{clazz}: #{send(:"valid_#{clazz}_instructions")}" 61 | end 62 | 63 | def valid_symbol_instructions?(instructions) 64 | valid_symbol_instructions.include?(instructions) 65 | end 66 | 67 | def valid_hash_instructions?(instructions) 68 | valid_hash_instructions.include?(instructions.keys.first) 69 | end 70 | 71 | def valid_symbol_instructions 72 | VALID_SYMBOL_INSTRUCTIONS 73 | end 74 | 75 | def valid_hash_instructions 76 | VALID_HASH_INSTRUCTIONS 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/active_graph/relationship.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | # Makes Neo4j Relationships more or less act like ActiveRecord objects. 3 | # See documentation at https://github.com/neo4jrb/neo4j/wiki/Neo4j%3A%3AActiveRel 4 | module Relationship 5 | extend ActiveSupport::Concern 6 | 7 | MARSHAL_INSTANCE_VARIABLES = [:@attributes, :@type, :@_persisted_obj] 8 | 9 | include ActiveGraph::Shared 10 | include ActiveGraph::Relationship::Initialize 11 | include ActiveGraph::Shared::Identity 12 | include ActiveGraph::Shared::Marshal 13 | include ActiveGraph::Shared::SerializedProperties 14 | include ActiveGraph::Relationship::Property 15 | include ActiveGraph::Relationship::Persistence 16 | include ActiveGraph::Relationship::Validations 17 | include ActiveGraph::Relationship::Callbacks 18 | include ActiveGraph::Relationship::Query 19 | include ActiveGraph::Relationship::Types 20 | include ActiveGraph::Shared::Enum 21 | include ActiveGraph::Shared::PermittedAttributes 22 | include ActiveGraph::Transactions 23 | 24 | class FrozenRelError < ActiveGraph::Error; end 25 | 26 | def initialize(from_node = nil, to_node = nil, args = nil) 27 | load_nodes(node_or_nil(from_node), node_or_nil(to_node)) 28 | resolved_args = hash_or_nil(from_node, args) 29 | symbol_args = sanitize_input_parameters(resolved_args) 30 | super(symbol_args) 31 | end 32 | 33 | def node_cypher_representation(node) 34 | node_class = node.class 35 | id_name = node_class.id_property_name 36 | labels = ':' + node_class.mapped_label_names.join(':') 37 | 38 | "(#{labels} {#{id_name}: #{node.id.inspect}})" 39 | end 40 | 41 | def neo4j_obj 42 | _persisted_obj || fail('Tried to access native neo4j object on a non persisted object') 43 | end 44 | 45 | included do 46 | include ActiveGraph::Timestamps if ActiveGraph::Config[:record_timestamps] 47 | 48 | def self.inherited(other) 49 | attributes.each_pair do |k, v| 50 | other.inherit_property k.to_sym, v.clone, declared_properties[k].options 51 | end 52 | super 53 | end 54 | end 55 | 56 | ActiveSupport.run_load_hooks(:relationship, self) 57 | 58 | private 59 | 60 | def node_or_nil(node) 61 | node.is_a?(ActiveGraph::Node) || node.is_a?(Integer) ? node : nil 62 | end 63 | 64 | def hash_or_nil(node_or_hash, hash_or_nil) 65 | hash_or_parameter?(node_or_hash) ? node_or_hash : hash_or_nil 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/e2e/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node do 2 | before do 3 | stub_node_class('SimpleClass') do 4 | property :name 5 | end 6 | end 7 | 8 | describe 'SimpleClass' do 9 | context 'when instantiated with new()' do 10 | subject do 11 | SimpleClass.new 12 | end 13 | 14 | it 'does not have any attributes' do 15 | expect(subject.attributes).to eq('name' => nil) 16 | end 17 | 18 | it 'returns nil when asking for a attribute' do 19 | expect(subject['name']).to be_nil 20 | end 21 | 22 | it 'can set attributes' do 23 | subject['name'] = 'foo' 24 | expect(subject['name']).to eq('foo') 25 | end 26 | 27 | it 'allows symbols instead of strings in [] and []= operator' do 28 | subject[:name] = 'foo' 29 | expect(subject['name']).to eq('foo') 30 | expect(subject[:name]).to eq('foo') 31 | end 32 | 33 | it 'allows setting attributes to nil' do 34 | subject['name'] = nil 35 | expect(subject['name']).to be_nil 36 | subject['name'] = 'foo' 37 | subject['name'] = nil 38 | expect(subject['name']).to be_nil 39 | end 40 | end 41 | 42 | context 'when instantiated with new(name: "foo")' do 43 | subject { SimpleClass.new(unknown: 'foo') } 44 | 45 | it 'does not allow setting undeclared properties' do 46 | expect { subject }.to raise_error ActiveGraph::Shared::Property::UndefinedPropertyError 47 | end 48 | end 49 | end 50 | 51 | describe 'question mark methods' do 52 | let(:node) { SimpleClass.new } 53 | 54 | it 'is false when unset' do 55 | expect(node.name?).to eq false 56 | end 57 | 58 | context 'value is true' do 59 | it 'changes when the value is present' do 60 | expect { node.name = 'true' }.to change { node.name? }.from(false).to(true) 61 | end 62 | end 63 | end 64 | 65 | describe '#query_attribute' do 66 | let(:node) { SimpleClass.new } 67 | 68 | subject { node.query_attribute(method_name) } 69 | 70 | context 'attribute is defined' do 71 | let(:method_name) { :name } 72 | 73 | it 'calls the question mark method' do 74 | expect(node).to receive(:name?) 75 | subject 76 | end 77 | end 78 | 79 | context 'attribute is not defined' do 80 | let(:method_name) { :foo } 81 | 82 | it do 83 | expect { subject }.to raise_error ActiveGraph::UnknownAttributeError 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/active_graph/migrations/base.rb: -------------------------------------------------------------------------------- 1 | module ActiveGraph 2 | module Migrations 3 | class Base 4 | include ActiveGraph::Migrations::Helpers 5 | include ActiveGraph::Migrations::Helpers::Schema 6 | include ActiveGraph::Migrations::Helpers::IdProperty 7 | include ActiveGraph::Migrations::Helpers::Relationships 8 | 9 | def initialize(migration_id, options = {}) 10 | @migration_id = migration_id 11 | @silenced = options[:silenced] 12 | end 13 | 14 | def migrate(method) 15 | Benchmark.realtime do 16 | method == :up ? migrate_up : migrate_down 17 | end 18 | end 19 | 20 | def up 21 | fail NotImplementedError 22 | end 23 | 24 | def down 25 | fail NotImplementedError 26 | end 27 | 28 | private 29 | 30 | def migrate_up 31 | schema = SchemaMigration.create!(migration_id: @migration_id, incomplete: true) 32 | begin 33 | run_migration(:up) 34 | rescue StandardError => e 35 | schema.destroy if transactions? 36 | handle_migration_error!(e) 37 | else 38 | schema.update!(incomplete: nil) 39 | end 40 | end 41 | 42 | def migrate_down 43 | schema = SchemaMigration.find_by!(migration_id: @migration_id) 44 | schema.update!(incomplete: true) 45 | begin 46 | run_migration(:down) 47 | rescue StandardError => e 48 | schema.update!(incomplete: nil) if transactions? 49 | handle_migration_error!(e) 50 | else 51 | schema.destroy 52 | end 53 | end 54 | 55 | def run_migration(direction) 56 | migration_transaction { log_queries { public_send(direction) } } 57 | end 58 | 59 | def handle_migration_error!(e) 60 | if e.is_a?(Neo4j::Driver::Exceptions::ClientException) && 61 | e.code == 'Neo.ClientError.Transaction.ForbiddenDueToTransactionType' 62 | fail MigrationError, "#{e.message}. Please add `disable_transactions!` in your migration file." 63 | else 64 | fail e 65 | end 66 | end 67 | 68 | def migration_transaction(&block) 69 | transactions? ? ActiveGraph::Base.transaction(&block) : block.call 70 | end 71 | 72 | def log_queries 73 | subscriber = ActiveGraph::Base.subscribe_to_query(&method(:output)) 74 | yield 75 | ensure 76 | ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | #--------------------------- 2 | # Style configuration 3 | #--------------------------- 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.1 7 | DisplayCopNames: true 8 | DisplayStyleGuide: true 9 | 10 | 11 | # Cop supports --auto-correct. 12 | # Configuration parameters: EnforcedStyle, SupportedStyles. 13 | Style/HashSyntax: 14 | Enabled: true 15 | EnforcedStyle: ruby19 16 | 17 | # Cop supports --auto-correct. 18 | Layout/SpaceInsideHashLiteralBraces: 19 | Enabled: true 20 | EnforcedStyle: no_space 21 | 22 | Style/SignalException: 23 | EnforcedStyle: semantic 24 | 25 | # I think this one is broken... 26 | Naming/FileName: 27 | Enabled: false 28 | 29 | Style/MultilineBlockChain: 30 | Enabled: false 31 | 32 | #--------------------------- 33 | # Don't intend to fix these: 34 | #--------------------------- 35 | 36 | # Cop supports --auto-correct. 37 | # Reason: Double spaces can be useful for grouping code 38 | Layout/EmptyLines: 39 | Enabled: false 40 | 41 | # Cop supports --auto-correct. 42 | # Reason: I have very big opinions on this one. See: 43 | # https://github.com/bbatsov/ruby-style-guide/issues/329 44 | # https://github.com/bbatsov/ruby-style-guide/pull/325 45 | Style/NegatedIf: 46 | Enabled: false 47 | 48 | # Cop supports --auto-correct. 49 | # Reason: I'm fine either way on this, but could maybe be convinced that this should be enforced 50 | Style/Not: 51 | Enabled: false 52 | 53 | # Cop supports --auto-correct. 54 | # Reason: I'm fine with this 55 | Style/PerlBackrefs: 56 | Enabled: false 57 | 58 | # Configuration parameters: Methods. 59 | # Reason: We should be able to specify full variable names, even if it's only one line 60 | Style/SingleLineBlockParams: 61 | Enabled: false 62 | 63 | # Reason: Switched `extend self` to `module_function` in id_property.rb but that caused errors 64 | Style/ModuleFunction: 65 | Enabled: false 66 | 67 | # Configuration parameters: AllowSafeAssignment. 68 | # Reason: I'm a proud user of assignment in conditionals. 69 | Lint/AssignmentInCondition: 70 | Enabled: false 71 | 72 | # Reason: Fine with any sort of lambda syntax 73 | Style/Lambda: 74 | Enabled: false 75 | 76 | # Reason: I'm proud to be part of the double negative Ruby tradition 77 | Style/DoubleNegation: 78 | Enabled: false 79 | 80 | # Reason: It's OK if the spec modules get long as long as they're well factored 81 | Metrics/ModuleLength: 82 | Exclude: 83 | - 'spec/**/*' 84 | 85 | # Reason: It's OK if the spec files get long as long as they're well factored 86 | Metrics/BlockLength: 87 | Exclude: 88 | - 'spec/**/*' 89 | -------------------------------------------------------------------------------- /spec/unit/node/persistance_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveGraph::Node::Persistence do 2 | let(:node) { double('a persisted node', exist?: true) } 3 | 4 | let(:clazz) do 5 | Class.new do 6 | include ActiveGraph::Shared 7 | include ActiveGraph::Shared::Identity 8 | include ActiveGraph::Node::Query 9 | include ActiveGraph::Node::Persistence 10 | include ActiveGraph::Node::Unpersisted 11 | include ActiveGraph::Node::HasN 12 | include ActiveGraph::Node::Property 13 | 14 | property :name 15 | property :age, type: Integer 16 | 17 | def self.fetch_upstream_primitive(_) 18 | nil 19 | end 20 | end 21 | end 22 | 23 | describe 'initialize' do 24 | it 'can take a hash of properties' do 25 | o = clazz.new(name: 'kalle', age: '42') 26 | expect(o.props).to eq(name: 'kalle', age: 42) 27 | end 28 | 29 | it 'raises an error when given a property which is not defined' do 30 | expect { clazz.new(unknown: true) }.to raise_error(ActiveGraph::Shared::Property::UndefinedPropertyError) 31 | end 32 | end 33 | 34 | describe 'new_record?' do 35 | it 'is true if it does not wrap a persisted node' do 36 | o = clazz.new 37 | expect(o.new_record?).to eq(true) 38 | end 39 | 40 | it 'is false if it does wrap a persisted node' do 41 | allow_any_instance_of(clazz).to receive(:_persisted_obj).and_return(node) 42 | o = clazz.new 43 | expect(o.new_record?).to eq(false) 44 | end 45 | end 46 | 47 | describe 'props' do 48 | it 'returns type casted attributes and undeclared attributes' do 49 | o = clazz.new 50 | o.age = '18' 51 | expect(o.age).to eq(18) 52 | end 53 | 54 | it 'does not return undefined properties' do 55 | o = clazz.new # name not defined 56 | o.age = '18' 57 | expect(o.props).to eq(age: 18) 58 | end 59 | end 60 | 61 | describe 'props_for_create' do 62 | let(:node) { clazz.new } 63 | before do 64 | clazz.send(:include, ActiveGraph::Node::IdProperty) 65 | clazz.id_property :uuid, auto: :uuid, constraint: false 66 | end 67 | 68 | it 'adds the primary key' do 69 | expect(node.props_for_create).to have_key(:uuid) 70 | end 71 | 72 | # This is important to be aware of. The UUID will be rebuilt each time it is called. 73 | it 'rebuilds each time called, setting a new UUID value' do 74 | props1 = node.props_for_create 75 | props2 = node.props_for_create 76 | expect(props1[:uuid]).not_to eq(props2[:uuid]) 77 | end 78 | end 79 | end 80 | --------------------------------------------------------------------------------