rails_age 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ # lib/apache_age/types/age_type_generator.rb
2
+ # Automatically generates ActiveModel::Type classes
3
+ # Dynamically builds this (as a concrete example):
4
+ # module ApacheAge
5
+ # module Types
6
+ # class CompanyType < ActiveModel::Type::Value
7
+ # def cast(value)
8
+ # case value
9
+ # when Nodes::Company
10
+ # value
11
+ # when Hash
12
+ # Nodes::Company.new(value)
13
+ # else
14
+ # nil
15
+ # end
16
+ # end
17
+ # def serialize(value)
18
+ # value.is_a?(Nodes::Company) ? value.attributes : nil
19
+ # end
20
+ # end
21
+ # end
22
+ # end
23
+ module ApacheAge
24
+ module Types
25
+ class AgeTypeGenerator
26
+ def self.create_type_for(klass)
27
+ Class.new(ActiveModel::Type::Value) do
28
+ define_method(:cast) do |value|
29
+ case value
30
+ when klass
31
+ value
32
+ when Hash
33
+ klass.new(value)
34
+ else
35
+ nil
36
+ end
37
+ end
38
+
39
+ define_method(:serialize) do |value|
40
+ value.is_a?(klass) ? value.attributes : nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # Usage (within an Age Model)
2
+ # validates_with UniqueEdgeValidator, attributes: [:employee_role, :start_node, :end_node]
3
+ # validates_with UniqueEdgeValidator, attributes: [:start_id, :employee_role, :end_id]
4
+ #
5
+ module ApacheAge
6
+ module Validators
7
+ class UniqueEdgeValidator < ActiveModel::Validator
8
+ def validate(record)
9
+ allowed_keys = record.age_properties.keys
10
+ attributes = options[:attributes] || []
11
+
12
+ edge_attribs =
13
+ attributes
14
+ .map { |attr| [attr, record.send(attr)] }.to_h
15
+ .symbolize_keys
16
+ .slice(*allowed_keys)
17
+
18
+ possible_end_keys = [:end_id, 'end_id', :end_node, 'end_node']
19
+ end_query =
20
+ if possible_end_keys.any? { |key| attributes.include?(key) }
21
+ end_query = query_node(record.end_node)
22
+ edge_attribs[:end_id] = end_query&.id
23
+ end_query
24
+ end
25
+
26
+ possible_start_keys = [:start_id, 'start_id', :start_node, 'start_node']
27
+ start_query =
28
+ if possible_start_keys.any? { |key| attributes.include?(key) }
29
+ start_query = query_node(record.start_node)
30
+ edge_attribs[:start_id] = start_query&.id
31
+ start_query
32
+ end
33
+ return if attributes.blank? && (end_query.blank? || start_query.blank?)
34
+
35
+ query = record.class.find_edge(edge_attribs.compact)
36
+ return if query.blank? || (query.id == record.id)
37
+
38
+ record.errors.add(:base, 'attribute combination not unique')
39
+ record.errors.add(:end_node, 'attribute combination not unique')
40
+ record.errors.add(:start_node, 'attribute combination not unique')
41
+ attributes.each { record.errors.add(_1, 'attribute combination not unique') }
42
+ end
43
+
44
+ private
45
+
46
+ def query_node(node)
47
+ return nil if node.blank?
48
+
49
+ node.persisted? ? node.class.find(node.id) : node.class.find_by(node.age_properties)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # Usage (within an Age Model)
2
+ # validates_with UniqueVertexValidator, attributes: [:first_name, :last_name, :gender]
3
+
4
+ # lib/apache_age/validators/unique_vertex_validator.rb
5
+ module ApacheAge
6
+ module Validators
7
+ class UniqueVertexValidator < ActiveModel::Validator
8
+ def validate(record)
9
+ allowed_keys = record.age_properties.keys
10
+ attributes = options[:attributes]
11
+ return if attributes.blank?
12
+
13
+ record_attribs =
14
+ attributes
15
+ .map { |attr| [attr, record.send(attr)] }
16
+ .to_h.symbolize_keys
17
+ .slice(*allowed_keys)
18
+ query = record.class.find_by(record_attribs)
19
+
20
+ # if no match is found or if it finds itself, it's valid
21
+ return if query.blank? || (query.id == record.id)
22
+
23
+ record.errors.add(:base, 'attribute combination not unique')
24
+ attributes.each { record.errors.add(_1, 'attribute combination not unique') }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ module ApacheAge
2
+ module VertexTypeValidator
3
+ def vertex_attribute(attribute_name, type_symbol, klass)
4
+ attribute attribute_name, type_symbol
5
+
6
+ validate do
7
+ value = send(attribute_name)
8
+ unless value.is_a?(klass)
9
+ errors.add(attribute_name, "must be a #{klass.name}")
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsAge
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/rails_age.rb CHANGED
@@ -1,14 +1,17 @@
1
- require "rails_age/version"
2
- require "rails_age/engine"
1
+ require 'rails_age/version'
2
+ require 'rails_age/engine'
3
3
 
4
4
  module RailsAge
5
5
  # Your code goes here...
6
6
  end
7
7
 
8
8
  module ApacheAge
9
- require "apache_age/class_methods"
10
- require "apache_age/common_methods"
11
- require "apache_age/edge"
12
- require "apache_age/entity"
13
- require "apache_age/vertex"
9
+ require 'apache_age/entities/class_methods'
10
+ require 'apache_age/entities/common_methods'
11
+ require 'apache_age/entities/edge'
12
+ require 'apache_age/entities/entity'
13
+ require 'apache_age/entities/vertex'
14
+ require 'apache_age/types/age_type_generator'
15
+ require 'apache_age/validators/unique_edge_validator'
16
+ require 'apache_age/validators/unique_vertex_validator'
14
17
  end
@@ -0,0 +1,91 @@
1
+ # lib/tasks/install.rake
2
+ # Usage: `rake rails_age:install`
3
+ #
4
+ namespace :rails_age do
5
+ desc "Copy migrations from rails_age to application and update schema"
6
+ task :install => :environment do
7
+ source = File.expand_path('../../../db/migrate', __FILE__)
8
+ destination = File.expand_path('../../../../db/migrate', __FILE__)
9
+
10
+ FileUtils.mkdir_p(destination) unless File.exists?(destination)
11
+
12
+ Dir.glob("#{source}/*.rb").each do |file|
13
+ filename = File.basename(file)
14
+ destination_file = File.join(destination, filename)
15
+
16
+ if File.exists?(destination_file)
17
+ puts "Skipping #{filename}, it already exists"
18
+ else
19
+ FileUtils.cp(file, destination_file)
20
+ puts "Copied #{filename} to #{destination}"
21
+ end
22
+ end
23
+
24
+ # Update the schema.rb file
25
+ schema_file = File.expand_path('../../../../db/schema.rb', __FILE__)
26
+ if File.exists?(schema_file)
27
+ content = File.read(schema_file)
28
+
29
+ # Add the necessary extensions and configurations at the top of the schema
30
+ insert_statements = <<-RUBY
31
+
32
+ # These are extensions that must be enabled in order to support this database
33
+ enable_extension "plpgsql"
34
+
35
+ # Allow age extension
36
+ execute('CREATE EXTENSION IF NOT EXISTS age;')
37
+
38
+ # Load the age code
39
+ execute("LOAD 'age';")
40
+
41
+ # Load the ag_catalog into the search path
42
+ execute('SET search_path = ag_catalog, "$user", public;')
43
+
44
+ # Create age_schema graph if it doesn't exist
45
+ execute("SELECT create_graph('age_schema');")
46
+
47
+ RUBY
48
+
49
+ unless content.include?(insert_statements.strip)
50
+ content.sub!(/^# These are extensions that must be enabled in order to support this database.*?\n\n/m, insert_statements)
51
+ end
52
+
53
+ # Remove unwanted schema statements
54
+ content.gsub!(/^.*?create_schema "ag_catalog".*?\n\n/m, '')
55
+ content.gsub!(/^.*?create_schema "age_schema".*?\n\n/m, '')
56
+ content.gsub!(/^.*?enable_extension "age".*?\n\n/m, '')
57
+ content.gsub!(/^.*?# Could not dump table "_ag_label_edge" because of following StandardError.*?\n\n/m, '')
58
+ content.gsub!(/^.*?# Could not dump table "_ag_label_vertex" because of following StandardError.*?\n\n/m, '')
59
+ content.gsub!(/^.*?# Could not dump table "ag_graph" because of following StandardError.*?\n\n/m, '')
60
+ content.gsub!(/^.*?# Could not dump table "ag_label" because of following StandardError.*?\n\n/m, '')
61
+ content.gsub!(/^.*?add_foreign_key "ag_label", "ag_graph".*?\n\n/m, '')
62
+
63
+ File.write(schema_file, content)
64
+ puts "Updated #{schema_file} with necessary extensions and configurations."
65
+ else
66
+ puts "schema.rb file not found. Please ensure migrations have been run."
67
+ end
68
+ end
69
+ end
70
+
71
+ # namespace :rails_age do
72
+ # desc "Copy migrations from rails_age to application"
73
+ # task :install => :environment do
74
+ # source = File.expand_path('../../../db/migrate', __FILE__)
75
+ # destination = File.expand_path('../../../../db/migrate', __FILE__)
76
+
77
+ # FileUtils.mkdir_p(destination) unless File.exists?(destination)
78
+
79
+ # Dir.glob("#{source}/*.rb").each do |file|
80
+ # filename = File.basename(file)
81
+ # destination_file = File.join(destination, filename)
82
+
83
+ # if File.exists?(destination_file)
84
+ # puts "Skipping #{filename}, it already exists"
85
+ # else
86
+ # FileUtils.cp(file, destination_file)
87
+ # puts "Copied #{filename} to #{destination}"
88
+ # end
89
+ # end
90
+ # end
91
+ # end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_age
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bill Tihen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-05-21 00:00:00.000000000 Z
11
+ date: 2024-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -16,29 +16,36 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 7.1.3.2
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
- version: 7.1.3.2
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rspec-rails
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - ">="
37
+ - - "~>"
32
38
  - !ruby/object:Gem::Version
33
- version: '0'
39
+ version: '6.0'
34
40
  type: :development
35
41
  prerelease: false
36
42
  version_requirements: !ruby/object:Gem::Requirement
37
43
  requirements:
38
- - - ">="
44
+ - - "~>"
39
45
  - !ruby/object:Gem::Version
40
- version: '0'
41
- description: Apache AGE plugin for Rails 7.1
46
+ version: '6.0'
47
+ description: This plugin integrates Apache AGE with Rails 7.x, providing tools and
48
+ helpers for working with graph databases within a Rails application.
42
49
  email:
43
50
  - btihen@gmail.com
44
51
  executables: []
@@ -60,21 +67,26 @@ files:
60
67
  - config/routes.rb
61
68
  - db/migrate/20240521062349_configure_apache_age.rb
62
69
  - db/schema.rb
63
- - lib/apache_age/class_methods.rb
64
- - lib/apache_age/common_methods.rb
65
- - lib/apache_age/edge.rb
66
- - lib/apache_age/entity.rb
67
- - lib/apache_age/vertex.rb
70
+ - lib/apache_age/entities/class_methods.rb
71
+ - lib/apache_age/entities/common_methods.rb
72
+ - lib/apache_age/entities/edge.rb
73
+ - lib/apache_age/entities/entity.rb
74
+ - lib/apache_age/entities/vertex.rb
75
+ - lib/apache_age/types/age_type_generator.rb
76
+ - lib/apache_age/validators/unique_edge_validator.rb
77
+ - lib/apache_age/validators/unique_vertex_validator.rb
78
+ - lib/apache_age/validators/vertex_type_validator.rb
68
79
  - lib/rails_age.rb
69
80
  - lib/rails_age/engine.rb
70
81
  - lib/rails_age/version.rb
82
+ - lib/tasks/install.rake
71
83
  - lib/tasks/rails_age_tasks.rake
72
84
  homepage: https://github.com/marpori/rails_age
73
85
  licenses:
74
86
  - MIT
75
87
  metadata:
76
88
  homepage_uri: https://github.com/marpori/rails_age
77
- source_code_uri: https://github.com/marpori/rails_age
89
+ source_code_uri: https://github.com/marpori/rails_age/blob/main
78
90
  changelog_uri: https://github.com/marpori/rails_age/blob/main/CHANGELOG.md
79
91
  post_install_message:
80
92
  rdoc_options: []
@@ -84,15 +96,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
84
96
  requirements:
85
97
  - - ">="
86
98
  - !ruby/object:Gem::Version
87
- version: '0'
99
+ version: '3.0'
88
100
  required_rubygems_version: !ruby/object:Gem::Requirement
89
101
  requirements:
90
102
  - - ">="
91
103
  - !ruby/object:Gem::Version
92
104
  version: '0'
93
105
  requirements: []
94
- rubygems_version: 3.5.9
106
+ rubygems_version: 3.5.10
95
107
  signing_key:
96
108
  specification_version: 4
97
- summary: Apache AGE plugin for Rails 7.1
109
+ summary: Apache AGE plugin for Rails 7.x
98
110
  test_files: []
@@ -1,75 +0,0 @@
1
- module ApacheAge
2
- module ClassMethods
3
- # for now we only allow one predertimed graph
4
- def create(attributes) = new(**attributes).save
5
-
6
- def find_by(attributes)
7
- where_clause = attributes.map { |k, v| "find.#{k} = '#{v}'" }.join(' AND ')
8
- cypher_sql = find_sql(where_clause)
9
- execute_find(cypher_sql)
10
- end
11
-
12
- def find(id)
13
- where_clause = "id(find) = #{id}"
14
- cypher_sql = find_sql(where_clause)
15
- execute_find(cypher_sql)
16
- end
17
-
18
- def all
19
- age_results = ActiveRecord::Base.connection.execute(all_sql)
20
- return [] if age_results.values.count.zero?
21
-
22
- age_results.values.map do |result|
23
- json_string = result.first.split('::').first
24
- hash = JSON.parse(json_string)
25
- attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
26
-
27
- new(**attribs)
28
- end
29
- end
30
-
31
- # Private stuff
32
-
33
- def age_graph = 'age_schema'
34
- def age_label = name.gsub('::', '__')
35
- def age_type = name.constantize.new.age_type
36
-
37
- def match_clause
38
- age_type == 'vertex' ? "(find:#{age_label})" : "()-[find:#{age_label}]->()"
39
- end
40
-
41
- def execute_find(cypher_sql)
42
- age_result = ActiveRecord::Base.connection.execute(cypher_sql)
43
- return nil if age_result.values.count.zero?
44
-
45
- age_type = age_result.values.first.first.split('::').last
46
- json_data = age_result.values.first.first.split('::').first
47
-
48
- hash = JSON.parse(json_data)
49
- attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
50
-
51
- new(**attribs)
52
- end
53
-
54
- def all_sql
55
- <<-SQL
56
- SELECT *
57
- FROM cypher('#{age_graph}', $$
58
- MATCH #{match_clause}
59
- RETURN find
60
- $$) as (#{age_label} agtype);
61
- SQL
62
- end
63
-
64
- def find_sql(where_clause)
65
- <<-SQL
66
- SELECT *
67
- FROM cypher('#{age_graph}', $$
68
- MATCH #{match_clause}
69
- WHERE #{where_clause}
70
- RETURN find
71
- $$) as (#{age_label} agtype);
72
- SQL
73
- end
74
- end
75
- end
@@ -1,126 +0,0 @@
1
- module ApacheAge
2
- module CommonMethods
3
- def initialize(**attributes)
4
- super
5
- return self unless age_type == 'edge'
6
-
7
- self.end_id ||= end_node.id if end_node
8
- self.start_id ||= start_node.id if start_node
9
- self.end_node ||= Entity.find(end_id) if end_id
10
- self.start_node ||= Entity.find(start_id) if start_id
11
- end
12
-
13
- # for now we just can just use one schema
14
- def age_graph = 'age_schema'
15
- def age_label = self.class.name.gsub('::', '__')
16
- def persisted? = id.present?
17
- def to_s = ":#{age_label} #{properties_to_s}"
18
-
19
- def to_h
20
- base_h = attributes.to_hash
21
- if age_type == 'edge'
22
- # remove the nodes (in attribute form and re-add in hash form)
23
- base_h = base_h.except('start_node', 'end_node')
24
- base_h[:end_node] = end_node.to_h if end_node
25
- base_h[:start_node] = start_node.to_h if start_node
26
- end
27
- base_h.symbolize_keys
28
- end
29
-
30
- def update_attributes(attribs)
31
- attribs.except(id:).each do |key, value|
32
- send("#{key}=", value) if respond_to?("#{key}=")
33
- end
34
- end
35
-
36
- def update(attribs)
37
- update_attributes(attribs)
38
- save
39
- end
40
-
41
- def save
42
- return false unless valid?
43
-
44
- cypher_sql = (persisted? ? update_sql : create_sql)
45
- response_hash = execute_sql(cypher_sql)
46
-
47
- self.id = response_hash['id']
48
-
49
- if age_type == 'edge'
50
- self.end_id = response_hash['end_id']
51
- self.start_id = response_hash['start_id']
52
- # reload the nodes? (can we change the nodes?)
53
- # self.end_node = ApacheAge::Entity.find(end_id)
54
- # self.start_node = ApacheAge::Entity.find(start_id)
55
- end
56
-
57
- self
58
- end
59
-
60
- def destroy
61
- match_clause = (age_type == 'vertex' ? "(done:#{age_label})" : "()-[done:#{age_label}]->()")
62
- delete_clause = (age_type == 'vertex' ? 'DETACH DELETE done' : 'DELETE done')
63
- cypher_sql =
64
- <<-SQL
65
- SELECT *
66
- FROM cypher('#{age_graph}', $$
67
- MATCH #{match_clause}
68
- WHERE id(done) = #{id}
69
- #{delete_clause}
70
- return done
71
- $$) as (deleted agtype);
72
- SQL
73
-
74
- hash = execute_sql(cypher_sql)
75
- return nil if hash.blank?
76
-
77
- self.id = nil
78
- self
79
- end
80
- alias destroy! destroy
81
- alias delete destroy
82
-
83
- # private
84
-
85
- def age_properties
86
- attrs = attributes.except('id')
87
- attrs = attrs.except('end_node', 'start_node', 'end_id', 'start_id') if age_type == 'edge'
88
- attrs.symbolize_keys
89
- end
90
-
91
- def age_hash
92
- hash =
93
- {
94
- id:,
95
- label: age_label,
96
- properties: age_properties
97
- }
98
- hash.merge!(end_id:, start_id:) if age_type == 'edge'
99
- hash.transform_keys(&:to_s)
100
- end
101
-
102
- def properties_to_s
103
- string_values =
104
- age_properties.each_with_object([]) do |(key, val), array|
105
- array << "#{key}: '#{val}'"
106
- end
107
- "{#{string_values.join(', ')}}"
108
- end
109
-
110
- def age_alias
111
- return nil if id.blank?
112
-
113
- # we start the alias with a since we can't start with a number
114
- 'a' + Digest::SHA256.hexdigest(id.to_s).to_i(16).to_s(36)[0..9]
115
- end
116
-
117
- def execute_sql(cypher_sql)
118
- age_result = ActiveRecord::Base.connection.execute(cypher_sql)
119
- age_type = age_result.values.first.first.split('::').last
120
- json_data = age_result.values.first.first.split('::').first
121
- # json_data = age_result.to_a.first.values.first.split("::#{age_type}").first
122
-
123
- JSON.parse(json_data)
124
- end
125
- end
126
- end
@@ -1,64 +0,0 @@
1
- module ApacheAge
2
- module Edge
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- include ActiveModel::Model
7
- include ActiveModel::Dirty
8
- include ActiveModel::Attributes
9
-
10
- attribute :id, :integer
11
- attribute :end_id, :integer
12
- attribute :start_id, :integer
13
- attribute :end_node # :vertex
14
- attribute :start_node # :vertex
15
-
16
- validates :end_node, :start_node, presence: true
17
-
18
- extend ApacheAge::ClassMethods
19
- include ApacheAge::CommonMethods
20
- end
21
-
22
- def age_type = 'edge'
23
-
24
- # AgeSchema::Edges::WorksAt.create(
25
- # start_node: fred, end_node: quarry, employee_role: 'Crane Operator'
26
- # )
27
- # SELECT *
28
- # FROM cypher('age_schema', $$
29
- # MATCH (start_vertex:Person), (end_vertex:Company)
30
- # WHERE id(start_vertex) = 1125899906842634 and id(end_vertex) = 844424930131976
31
- # CREATE (start_vertex)-[edge:WorksAt {employee_role: 'Crane Operator'}]->(end_vertex)
32
- # RETURN edge
33
- # $$) as (edge agtype);
34
- def create_sql
35
- self.start_node = start_node.save unless start_node.persisted?
36
- self.end_node = end_node.save unless end_node.persisted?
37
- <<-SQL
38
- SELECT *
39
- FROM cypher('#{age_graph}', $$
40
- MATCH (from_node:#{start_node.age_label}), (to_node:#{end_node.age_label})
41
- WHERE id(from_node) = #{start_node.id} and id(to_node) = #{end_node.id}
42
- CREATE (from_node)-[edge#{self}]->(to_node)
43
- RETURN edge
44
- $$) as (edge agtype);
45
- SQL
46
- end
47
-
48
- # So far just properties of string type with '' around them
49
- def update_sql
50
- alias_name = age_alias || age_label.downcase
51
- set_caluse =
52
- age_properties.map { |k, v| v ? "#{alias_name}.#{k} = '#{v}'" : "#{alias_name}.#{k} = NULL" }.join(', ')
53
- <<-SQL
54
- SELECT *
55
- FROM cypher('#{age_graph}', $$
56
- MATCH ()-[#{alias_name}:#{age_label}]->()
57
- WHERE id(#{alias_name}) = #{id}
58
- SET #{set_caluse}
59
- RETURN #{alias_name}
60
- $$) as (#{age_label} agtype);
61
- SQL
62
- end
63
- end
64
- end