rails_age 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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