activecypher 0.0.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.
- checksums.yaml +4 -4
- data/lib/active_cypher/associations/collection_proxy.rb +144 -0
- data/lib/active_cypher/associations.rb +537 -0
- data/lib/active_cypher/base.rb +47 -0
- data/lib/active_cypher/bolt/connection.rb +525 -0
- data/lib/active_cypher/bolt/driver.rb +144 -0
- data/lib/active_cypher/bolt/handlers.rb +10 -0
- data/lib/active_cypher/bolt/message_reader.rb +100 -0
- data/lib/active_cypher/bolt/message_writer.rb +53 -0
- data/lib/active_cypher/bolt/messaging.rb +307 -0
- data/lib/active_cypher/bolt/packstream.rb +319 -0
- data/lib/active_cypher/bolt/result.rb +82 -0
- data/lib/active_cypher/bolt/session.rb +201 -0
- data/lib/active_cypher/bolt/transaction.rb +211 -0
- data/lib/active_cypher/bolt/version_encoding.rb +41 -0
- data/lib/active_cypher/bolt.rb +7 -0
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
- data/lib/active_cypher/connection_factory.rb +130 -0
- data/lib/active_cypher/connection_handler.rb +9 -0
- data/lib/active_cypher/connection_pool.rb +123 -0
- data/lib/active_cypher/connection_url_resolver.rb +137 -0
- data/lib/active_cypher/cypher_config.rb +50 -0
- data/lib/active_cypher/generators/install_generator.rb +23 -0
- data/lib/active_cypher/generators/node_generator.rb +32 -0
- data/lib/active_cypher/generators/relationship_generator.rb +33 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
- data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
- data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
- data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
- data/lib/active_cypher/logging.rb +44 -0
- data/lib/active_cypher/model/abstract.rb +87 -0
- data/lib/active_cypher/model/attributes.rb +24 -0
- data/lib/active_cypher/model/callbacks.rb +44 -0
- data/lib/active_cypher/model/connection_handling.rb +76 -0
- data/lib/active_cypher/model/connection_owner.rb +50 -0
- data/lib/active_cypher/model/core.rb +45 -0
- data/lib/active_cypher/model/countable.rb +30 -0
- data/lib/active_cypher/model/destruction.rb +49 -0
- data/lib/active_cypher/model/inspectable.rb +28 -0
- data/lib/active_cypher/model/persistence.rb +182 -0
- data/lib/active_cypher/model/querying.rb +67 -0
- data/lib/active_cypher/railtie.rb +34 -0
- data/lib/active_cypher/relation.rb +190 -0
- data/lib/active_cypher/relationship.rb +233 -0
- data/lib/active_cypher/runtime_registry.rb +8 -0
- data/lib/active_cypher/scoping.rb +97 -0
- data/lib/active_cypher/utils/logger.rb +100 -0
- data/lib/active_cypher/version.rb +5 -0
- data/lib/activecypher.rb +108 -0
- data/lib/cyrel/call_procedure.rb +29 -0
- data/lib/cyrel/clause/call.rb +46 -0
- data/lib/cyrel/clause/call_subquery.rb +40 -0
- data/lib/cyrel/clause/create.rb +33 -0
- data/lib/cyrel/clause/delete.rb +41 -0
- data/lib/cyrel/clause/limit.rb +33 -0
- data/lib/cyrel/clause/match.rb +40 -0
- data/lib/cyrel/clause/merge.rb +34 -0
- data/lib/cyrel/clause/order_by.rb +78 -0
- data/lib/cyrel/clause/remove.rb +75 -0
- data/lib/cyrel/clause/return.rb +90 -0
- data/lib/cyrel/clause/set.rb +97 -0
- data/lib/cyrel/clause/skip.rb +34 -0
- data/lib/cyrel/clause/where.rb +42 -0
- data/lib/cyrel/clause/with.rb +94 -0
- data/lib/cyrel/clause.rb +25 -0
- data/lib/cyrel/direction.rb +18 -0
- data/lib/cyrel/expression/alias.rb +27 -0
- data/lib/cyrel/expression/base.rb +101 -0
- data/lib/cyrel/expression/case.rb +45 -0
- data/lib/cyrel/expression/comparison.rb +60 -0
- data/lib/cyrel/expression/exists.rb +42 -0
- data/lib/cyrel/expression/function_call.rb +57 -0
- data/lib/cyrel/expression/literal.rb +33 -0
- data/lib/cyrel/expression/logical.rb +38 -0
- data/lib/cyrel/expression/operator.rb +27 -0
- data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
- data/lib/cyrel/expression/property_access.rb +25 -0
- data/lib/cyrel/expression.rb +56 -0
- data/lib/cyrel/functions.rb +116 -0
- data/lib/cyrel/node.rb +397 -0
- data/lib/cyrel/parameterizable.rb +20 -0
- data/lib/cyrel/pattern/node.rb +66 -0
- data/lib/cyrel/pattern/path.rb +41 -0
- data/lib/cyrel/pattern/relationship.rb +74 -0
- data/lib/cyrel/pattern.rb +8 -0
- data/lib/cyrel/query.rb +497 -0
- data/lib/cyrel/return_only.rb +26 -0
- data/lib/cyrel/types/hash_type.rb +22 -0
- data/lib/cyrel/types/symbol_type.rb +13 -0
- data/lib/cyrel.rb +72 -0
- data/lib/tasks/active_cypher_tasks.rake +6 -0
- data/sig/activecypher.rbs +4 -0
- metadata +173 -10
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Model
|
5
|
+
# @!parse
|
6
|
+
# # Querying: The module that lets you pretend your graph is just a really weird table.
|
7
|
+
# # Because what’s more fun than chaining scopes and pretending you’re not writing Cypher by hand?
|
8
|
+
module Querying
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
class_methods do
|
12
|
+
# -- default label -----------------------------------------------
|
13
|
+
# Returns the symbolic label name for the model, e.g., :user_node
|
14
|
+
# @return [Symbol] The label name for the model
|
15
|
+
def label_name = model_name.element.to_sym
|
16
|
+
|
17
|
+
# -- basic query builders ----------------------------------------
|
18
|
+
# Return a base Relation, applying the default scope if it exists
|
19
|
+
# @return [Relation] The base relation for the model
|
20
|
+
# Because nothing says "default" like a scope you forgot you set.
|
21
|
+
def all
|
22
|
+
relation = Relation.new(self)
|
23
|
+
relation = relation.merge(_default_scope.call(self)) if _default_scope
|
24
|
+
relation
|
25
|
+
end
|
26
|
+
|
27
|
+
# Proxy methods to chain basic query clauses off `all`
|
28
|
+
# @param conditions [Hash, Cyrel::Expression::Base] The conditions for the where clause
|
29
|
+
# @return [Relation]
|
30
|
+
def where(conditions) = all.where(conditions)
|
31
|
+
|
32
|
+
# @param val [Integer] The limit value
|
33
|
+
# @return [Relation]
|
34
|
+
def limit(val) = all.limit(val)
|
35
|
+
|
36
|
+
# @return [Relation]
|
37
|
+
def order(*) = all.order(*)
|
38
|
+
|
39
|
+
# -- find / create -----------------------------------------------
|
40
|
+
# Find a node by internal DB ID. Returns the record or dies dramatically.
|
41
|
+
# @param internal_db_id [String] The internal database ID
|
42
|
+
# @return [Object] The found record
|
43
|
+
# @raise [ActiveCypher::RecordNotFound] if not found
|
44
|
+
# Because sometimes you want to find a node, and sometimes you want to find existential dread.
|
45
|
+
def find(internal_db_id)
|
46
|
+
node_alias = :n
|
47
|
+
|
48
|
+
query = Cyrel
|
49
|
+
.match(Cyrel.node(label_name).as(node_alias))
|
50
|
+
.where(Cyrel.element_id(node_alias).eq(internal_db_id))
|
51
|
+
.return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
|
52
|
+
.limit(1)
|
53
|
+
|
54
|
+
Relation.new(self, query).first or
|
55
|
+
raise ActiveCypher::RecordNotFound,
|
56
|
+
"#{name} with internal_id=#{internal_db_id.inspect} not found"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Instantiates and immediately saves a new record. YOLO mode.
|
60
|
+
# @param attrs [Hash] Attributes for the new record
|
61
|
+
# @return [Object] The new, possibly persisted record
|
62
|
+
# Because sometimes you just want to live dangerously.
|
63
|
+
def create(attrs = {}) = new(attrs).tap(&:save)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/railtie'
|
4
|
+
require 'active_cypher/logging'
|
5
|
+
require 'active_cypher/cypher_config'
|
6
|
+
|
7
|
+
module ActiveCypher
|
8
|
+
class Railtie < ::Rails::Railtie
|
9
|
+
initializer 'active_cypher.logger' do
|
10
|
+
ActiveSupport.on_load(:active_cypher) do
|
11
|
+
ActiveCypher::Logging.backend = Rails.logger
|
12
|
+
|
13
|
+
# Honour Rails.env level unless the user set AC_LOG_LEVEL
|
14
|
+
ActiveCypher::Logging.backend.level = Rails.logger.level unless ENV['AC_LOG_LEVEL']
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
initializer 'active_cypher.load_multi_db' do |_app|
|
19
|
+
configs = ActiveCypher::CypherConfig.for('*') # entire merged hash
|
20
|
+
%i[writing reading analytics].each do |role|
|
21
|
+
next unless (cfg = configs[role])
|
22
|
+
|
23
|
+
pool = ActiveCypher::ConnectionPool.new(cfg)
|
24
|
+
ActiveCypher::Base.connection_handler.set(role, :default, pool)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
generators do
|
29
|
+
require 'active_cypher/generators/install_generator'
|
30
|
+
require 'active_cypher/generators/node_generator'
|
31
|
+
require 'active_cypher/generators/relationship_generator'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/module/delegation'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
# Chainable, lazily evaluated Cypher query.
|
7
|
+
# Because what you really want is to pretend your database is just a big Ruby array.
|
8
|
+
class Relation
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
attr_reader :model_class, :cyrel_query
|
12
|
+
|
13
|
+
# Methods that trigger query execution
|
14
|
+
# Because nothing says "performance" like loading everything at once.
|
15
|
+
LOAD_METHODS = %i[each to_a first last count size length any? empty?].freeze
|
16
|
+
|
17
|
+
# ------------------------------------------------------------------
|
18
|
+
# Construction
|
19
|
+
# ------------------------------------------------------------------
|
20
|
+
# Initializes a Relation. Because direct SQL was too mainstream.
|
21
|
+
# @param model_class [Class] The model class for the relation
|
22
|
+
# @param cyrel_query [Object, nil] The Cyrel query object
|
23
|
+
def initialize(model_class, cyrel_query = nil)
|
24
|
+
@model_class = model_class
|
25
|
+
@cyrel_query = cyrel_query || default_query
|
26
|
+
@records = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# ------------------------------------------------------------------
|
30
|
+
# Query‑builder helpers
|
31
|
+
# ------------------------------------------------------------------
|
32
|
+
# Because chaining methods is more fun than writing actual queries.
|
33
|
+
# @param conditions [Hash, Cyrel::Expression::Base] The conditions for the where clause
|
34
|
+
# @return [Relation] A new relation with the where clause applied
|
35
|
+
def where(conditions)
|
36
|
+
new_query = @cyrel_query.clone
|
37
|
+
node_alias = :n
|
38
|
+
|
39
|
+
case conditions
|
40
|
+
when Hash
|
41
|
+
conditions.each do |key, value|
|
42
|
+
expr = Cyrel.prop(node_alias, key).eq(value)
|
43
|
+
new_query = new_query.where(expr)
|
44
|
+
end
|
45
|
+
when Cyrel::Expression::Base
|
46
|
+
new_query = new_query.where(conditions)
|
47
|
+
else
|
48
|
+
raise ArgumentError,
|
49
|
+
"Unsupported type for #where: #{conditions.class}. " \
|
50
|
+
'Pass a Hash or Cyrel::Expression.'
|
51
|
+
end
|
52
|
+
|
53
|
+
spawn(new_query)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Because sometimes you want less data, but never less abstraction.
|
57
|
+
# @param value [Integer] The limit value
|
58
|
+
# @return [Relation]
|
59
|
+
def limit(value)
|
60
|
+
spawn(@cyrel_query.clone.limit(value))
|
61
|
+
end
|
62
|
+
|
63
|
+
# ORDER support: coming soon, like your next vacation.
|
64
|
+
# @return [Relation]
|
65
|
+
def order(*_args)
|
66
|
+
# TODO: Implement proper ORDER support
|
67
|
+
spawn(@cyrel_query)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Merges another relation, because why not double the confusion.
|
71
|
+
# @param _other [Relation]
|
72
|
+
# @return [Relation]
|
73
|
+
def merge(_other)
|
74
|
+
spawn(@cyrel_query.clone)
|
75
|
+
end
|
76
|
+
|
77
|
+
# ------------------------------------------------------------------
|
78
|
+
# Enumerable / loader
|
79
|
+
# ------------------------------------------------------------------
|
80
|
+
# Pretend this is just an array. Your database will never know.
|
81
|
+
# @yield [record] Yields each record in the relation
|
82
|
+
def each(&)
|
83
|
+
load_records unless loaded?
|
84
|
+
@records.each(&)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Because everyone wants to be first.
|
88
|
+
# @return [Object, nil] The first record
|
89
|
+
def first
|
90
|
+
load_records unless loaded?
|
91
|
+
@records.first
|
92
|
+
end
|
93
|
+
|
94
|
+
# Or last, if you're feeling dramatic.
|
95
|
+
# @return [Object, nil] The last record
|
96
|
+
def last
|
97
|
+
load_records unless loaded?
|
98
|
+
@records.last
|
99
|
+
end
|
100
|
+
|
101
|
+
# Counting records: the only math most devs trust.
|
102
|
+
# @return [Integer] The number of records
|
103
|
+
def count
|
104
|
+
load_records unless loaded?
|
105
|
+
@records.count
|
106
|
+
end
|
107
|
+
alias size count
|
108
|
+
alias length count
|
109
|
+
|
110
|
+
# ------------------------------------------------------------------
|
111
|
+
# Internal helpers
|
112
|
+
# ------------------------------------------------------------------
|
113
|
+
|
114
|
+
# Checks if we've already loaded the records, or if we're still living in denial.
|
115
|
+
# @return [Boolean]
|
116
|
+
def loaded?
|
117
|
+
!@records.nil?
|
118
|
+
end
|
119
|
+
|
120
|
+
# Resets the loaded records, for when you want to pretend nothing ever happened.
|
121
|
+
# @return [void]
|
122
|
+
def reset!
|
123
|
+
@records = nil
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
# Default: MATCH (n:Label) RETURN n, elementId(n) AS internal_id
|
129
|
+
# Because writing Cypher by hand is for people with too much free time.
|
130
|
+
# @return [Object] The default Cyrel query
|
131
|
+
def default_query
|
132
|
+
label = model_class.model_name.element
|
133
|
+
node_alias = :n
|
134
|
+
|
135
|
+
Cyrel
|
136
|
+
.match(Cyrel.node(label).as(node_alias))
|
137
|
+
.return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
|
138
|
+
end
|
139
|
+
|
140
|
+
# Actually loads the records from the database, shattering the illusion of laziness.
|
141
|
+
# @return [void]
|
142
|
+
def load_records
|
143
|
+
cypher, params = @cyrel_query.to_cypher
|
144
|
+
raw = model_class.connection.execute_cypher(
|
145
|
+
cypher, params || {}, 'Load Relation'
|
146
|
+
)
|
147
|
+
@records = map_results(raw)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Maps raw database results into something you can almost believe is a real object.
|
151
|
+
# @param raw_results [Array<Hash, Array>] The raw results from the database
|
152
|
+
# @return [Array<Object>] The mapped records
|
153
|
+
def map_results(raw_results)
|
154
|
+
raw_results.map do |row|
|
155
|
+
# ------------------------------------------------------------
|
156
|
+
# 1. Pull out the node payload and the elementId string
|
157
|
+
# ------------------------------------------------------------
|
158
|
+
if row.is_a?(Hash)
|
159
|
+
node_payload = row[:n] || row['n'] || row
|
160
|
+
element_id = row[:internal_id] || row['internal_id']
|
161
|
+
else # Array row: [node, id]
|
162
|
+
node_payload, element_id = row
|
163
|
+
end
|
164
|
+
|
165
|
+
# ------------------------------------------------------------
|
166
|
+
# 2. If the node is still in Bolt array form [78, [...]],
|
167
|
+
# convert it to { "name"=>"Bob", ... }
|
168
|
+
# ------------------------------------------------------------
|
169
|
+
if node_payload.is_a?(Array) && node_payload.first == 78
|
170
|
+
# Re‑use the adapter's private helper for consistency
|
171
|
+
node_payload = model_class.connection
|
172
|
+
.send(:process_node, node_payload)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Now we have a plain hash of properties
|
176
|
+
attrs = node_payload.with_indifferent_access
|
177
|
+
attrs[:internal_id] = element_id if element_id
|
178
|
+
# Use instantiate instead of new to mark records as persisted
|
179
|
+
model_class.instantiate(attrs)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Spawns a new Relation, because immutability is trendy.
|
184
|
+
# @param new_query [Object] The new Cyrel query
|
185
|
+
# @return [Relation]
|
186
|
+
def spawn(new_query)
|
187
|
+
self.class.new(@model_class, new_query)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/active_cypher/relationship.rb
|
4
|
+
# ------------------------------------------------------------------
|
5
|
+
# Graph *edge* model — mirrors ActiveRecord::Base but for Cypher
|
6
|
+
# relationships.
|
7
|
+
#
|
8
|
+
# Example:
|
9
|
+
#
|
10
|
+
# class WorksAtRelationship < ApplicationGraphRelationship
|
11
|
+
# from_class 'PersonNode'
|
12
|
+
# to_class 'CompanyNode'
|
13
|
+
# type 'WORKS_AT'
|
14
|
+
#
|
15
|
+
# attribute :title, :string
|
16
|
+
# attribute :since, :integer
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# Persist with:
|
20
|
+
#
|
21
|
+
# WorksAtRelationship.create({title: 'CTO'},
|
22
|
+
# from_node: person,
|
23
|
+
# to_node: company)
|
24
|
+
# ------------------------------------------------------------------
|
25
|
+
require 'active_model'
|
26
|
+
require 'active_support'
|
27
|
+
require 'active_support/core_ext/class/attribute'
|
28
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
29
|
+
|
30
|
+
module ActiveCypher
|
31
|
+
class Relationship
|
32
|
+
# --------------------------------------------------------------
|
33
|
+
# Mix‑ins
|
34
|
+
# --------------------------------------------------------------
|
35
|
+
include ActiveModel::API
|
36
|
+
include ActiveModel::Attributes
|
37
|
+
include ActiveModel::Dirty
|
38
|
+
include ActiveModel::Naming
|
39
|
+
|
40
|
+
include Model::ConnectionOwner
|
41
|
+
include Logging
|
42
|
+
include Model::Abstract
|
43
|
+
include Model::ConnectionHandling
|
44
|
+
include Model::Callbacks
|
45
|
+
include Model::Countable
|
46
|
+
|
47
|
+
# --------------------------------------------------------------
|
48
|
+
# Attributes
|
49
|
+
# --------------------------------------------------------------
|
50
|
+
attribute :internal_id, :string
|
51
|
+
|
52
|
+
# --------------------------------------------------------------
|
53
|
+
# Connection fallback
|
54
|
+
# --------------------------------------------------------------
|
55
|
+
# Relationship classes usually share the same Bolt pool as the
|
56
|
+
# node they originate from; delegate there unless the relationship
|
57
|
+
# class was given its own pool explicitly.
|
58
|
+
#
|
59
|
+
# WorksAtRelationship.connection # -> PersonNode.connection
|
60
|
+
#
|
61
|
+
def self.connection
|
62
|
+
return @connection if defined?(@connection) && @connection
|
63
|
+
|
64
|
+
begin
|
65
|
+
from_class.constantize.connection
|
66
|
+
rescue StandardError
|
67
|
+
nil
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# --------------------------------------------------------------
|
72
|
+
# DSL helpers
|
73
|
+
# --------------------------------------------------------------
|
74
|
+
class_attribute :_from_class_name, instance_writer: false
|
75
|
+
class_attribute :_to_class_name, instance_writer: false
|
76
|
+
class_attribute :_relationship_type, instance_writer: false
|
77
|
+
|
78
|
+
class << self
|
79
|
+
attr_reader :last_internal_id
|
80
|
+
|
81
|
+
# -- endpoints ------------------------------------------------
|
82
|
+
def from_class(value = nil)
|
83
|
+
return _from_class_name if value.nil?
|
84
|
+
|
85
|
+
self._from_class_name = value.to_s
|
86
|
+
end
|
87
|
+
alias from_class_name from_class
|
88
|
+
|
89
|
+
def to_class(value = nil)
|
90
|
+
return _to_class_name if value.nil?
|
91
|
+
|
92
|
+
self._to_class_name = value.to_s
|
93
|
+
end
|
94
|
+
alias to_class_name to_class
|
95
|
+
|
96
|
+
# -- type -----------------------------------------------------
|
97
|
+
def type(value = nil)
|
98
|
+
return _relationship_type if value.nil?
|
99
|
+
|
100
|
+
self._relationship_type = value.to_s.upcase
|
101
|
+
end
|
102
|
+
alias relationship_type type
|
103
|
+
|
104
|
+
# -- factories -----------------------------------------------
|
105
|
+
# Mirrors ActiveRecord.create
|
106
|
+
def create(attrs = {}, from_node:, to_node:)
|
107
|
+
new(attrs, from_node: from_node, to_node: to_node).tap(&:save)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Instantiate from DB row, marking the instance as persisted.
|
111
|
+
def instantiate(attributes, from_node: nil, to_node: nil)
|
112
|
+
instance = allocate
|
113
|
+
instance.send(:init_with_attributes,
|
114
|
+
attributes,
|
115
|
+
from_node: from_node,
|
116
|
+
to_node: to_node)
|
117
|
+
instance
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# --------------------------------------------------------------
|
122
|
+
# Life‑cycle
|
123
|
+
# --------------------------------------------------------------
|
124
|
+
attr_accessor :from_node, :to_node
|
125
|
+
attr_reader :new_record
|
126
|
+
|
127
|
+
def initialize(attrs = {}, from_node: nil, to_node: nil)
|
128
|
+
_run(:initialize) do
|
129
|
+
super()
|
130
|
+
assign_attributes(attrs) if attrs
|
131
|
+
@from_node = from_node
|
132
|
+
@to_node = to_node
|
133
|
+
@new_record = true
|
134
|
+
clear_changes_information
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def new_record? = @new_record
|
139
|
+
def persisted? = !new_record? && internal_id.present?
|
140
|
+
def destroyed? = @destroyed == true
|
141
|
+
|
142
|
+
# --------------------------------------------------------------
|
143
|
+
# Persistence API
|
144
|
+
# --------------------------------------------------------------
|
145
|
+
def save
|
146
|
+
_run(:save) do
|
147
|
+
if new_record?
|
148
|
+
_run(:create) { create_relationship }
|
149
|
+
else
|
150
|
+
_run(:update) { update_relationship }
|
151
|
+
end
|
152
|
+
end
|
153
|
+
rescue StandardError => e
|
154
|
+
log_error "Failed to save #{self.class}: #{e.class} – #{e.message}"
|
155
|
+
false
|
156
|
+
end
|
157
|
+
|
158
|
+
def destroy
|
159
|
+
_run(:destroy) do
|
160
|
+
raise 'Cannot destroy a new relationship' if new_record?
|
161
|
+
raise 'Relationship already destroyed' if destroyed?
|
162
|
+
|
163
|
+
cypher = 'MATCH ()-[r]-() WHERE elementId(r) = $id DELETE r'
|
164
|
+
params = { id: internal_id }
|
165
|
+
|
166
|
+
self.class.connection.execute_cypher(cypher, params, 'Destroy Relationship')
|
167
|
+
@destroyed = true
|
168
|
+
freeze
|
169
|
+
true
|
170
|
+
end
|
171
|
+
rescue StandardError => e
|
172
|
+
log_error "Failed to destroy #{self.class}: #{e.class} – #{e.message}"
|
173
|
+
false
|
174
|
+
end
|
175
|
+
|
176
|
+
# --------------------------------------------------------------
|
177
|
+
# Private helpers
|
178
|
+
# --------------------------------------------------------------
|
179
|
+
private
|
180
|
+
|
181
|
+
def create_relationship
|
182
|
+
raise 'Source node must be persisted' unless from_node&.persisted?
|
183
|
+
raise 'Target node must be persisted' unless to_node&.persisted?
|
184
|
+
|
185
|
+
props = attributes.except('internal_id').compact
|
186
|
+
rel_ty = self.class.relationship_type
|
187
|
+
arrow = '->' # outgoing by default
|
188
|
+
|
189
|
+
parts = []
|
190
|
+
parts << 'MATCH (a) WHERE elementId(a) = $from_id'
|
191
|
+
parts << 'MATCH (b) WHERE elementId(b) = $to_id'
|
192
|
+
parts << "CREATE (a)-[r:#{rel_ty}]#{arrow}(b)"
|
193
|
+
parts << 'SET r += $props' unless props.empty? # only if we have props
|
194
|
+
parts << 'RETURN elementId(r) AS rid'
|
195
|
+
|
196
|
+
cypher = parts.join(' ')
|
197
|
+
params = {
|
198
|
+
from_id: from_node.internal_id,
|
199
|
+
to_id: to_node.internal_id,
|
200
|
+
props: props
|
201
|
+
}
|
202
|
+
|
203
|
+
row = self.class.connection.execute_cypher(cypher, params, 'Create Relationship').first
|
204
|
+
rid = row && (row[:rid] || row['rid']) or raise 'Relationship creation returned no id'
|
205
|
+
|
206
|
+
self.internal_id = rid
|
207
|
+
self.class.instance_variable_set(:@last_internal_id, rid)
|
208
|
+
@new_record = false
|
209
|
+
changes_applied
|
210
|
+
true
|
211
|
+
rescue StandardError => e
|
212
|
+
log_error "Failed to save #{self.class}: #{e.class} – #{e.message}"
|
213
|
+
false
|
214
|
+
end
|
215
|
+
|
216
|
+
def update_relationship
|
217
|
+
changes = changes_to_save
|
218
|
+
return true if changes.empty?
|
219
|
+
|
220
|
+
cypher = <<~CYPHER
|
221
|
+
MATCH ()-[r]-() WHERE elementId(r) = $id
|
222
|
+
SET r += $props
|
223
|
+
CYPHER
|
224
|
+
params = { id: internal_id, props: changes }
|
225
|
+
|
226
|
+
self.class.connection.execute_cypher(cypher, params, 'Update Relationship')
|
227
|
+
changes_applied
|
228
|
+
true
|
229
|
+
end
|
230
|
+
|
231
|
+
def changes_to_save = changes.transform_values(&:last)
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
# Provides scoping capabilities for ActiveCypher models.
|
7
|
+
# Allows defining reusable query constraints as class methods.
|
8
|
+
module Scoping
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
included do
|
12
|
+
# Stores defined scopes { scope_name: lambda }
|
13
|
+
class_attribute :_scopes, instance_accessor: false, default: {}
|
14
|
+
# Stores the default scope lambda
|
15
|
+
class_attribute :_default_scope, instance_accessor: false, default: nil
|
16
|
+
end
|
17
|
+
|
18
|
+
class_methods do
|
19
|
+
# Defines a scope for the model.
|
20
|
+
#
|
21
|
+
# A scope represents a commonly used query constraint that can be chained
|
22
|
+
# like other query methods.
|
23
|
+
#
|
24
|
+
# @param name [Symbol] The name of the scope. This will define a class method
|
25
|
+
# with the same name.
|
26
|
+
# @param body [Proc] A lambda or proc that implements the scope's logic.
|
27
|
+
# It will be called with the current relation (or the base model class)
|
28
|
+
# and any arguments passed to the scope. It should return a Relation.
|
29
|
+
# @param block [Proc] Alternative way to pass the scope body as a block.
|
30
|
+
#
|
31
|
+
# @example
|
32
|
+
# class User < ActiveCypher::Base
|
33
|
+
# scope :active, -> { where(status: 'active') }
|
34
|
+
# scope :created_since, ->(date) { where("n.created_at > $date", date: date) } # Assuming Cyrel supports string conditions
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# User.active.created_since(1.week.ago).to_a
|
38
|
+
#
|
39
|
+
def scope(name, body, &block)
|
40
|
+
name = name.to_sym
|
41
|
+
body = block if block_given?
|
42
|
+
|
43
|
+
raise ArgumentError, 'The scope body needs to be a Proc or lambda.' unless body.is_a?(Proc)
|
44
|
+
|
45
|
+
# Store the scope lambda
|
46
|
+
self._scopes = _scopes.merge(name => body)
|
47
|
+
|
48
|
+
# Define the class method for the scope
|
49
|
+
# This method will apply the scope logic to the current relation or create a new one.
|
50
|
+
define_singleton_method(name) do |*args|
|
51
|
+
# Get the base relation (starts with all records of this model)
|
52
|
+
base_relation = all
|
53
|
+
|
54
|
+
# Execute the scope's lambda. It should return a Relation
|
55
|
+
# containing the specific conditions of the scope.
|
56
|
+
# We pass `self` (the model class) and any arguments.
|
57
|
+
scope_relation = body.call(self, *args)
|
58
|
+
|
59
|
+
unless scope_relation.is_a?(ActiveCypher::Relation)
|
60
|
+
# If the lambda doesn't return a Relation, we might need to handle
|
61
|
+
# merging conditions differently, but for now, enforce returning a Relation.
|
62
|
+
raise ArgumentError, 'Scope body must return an ActiveCypher::Relation.'
|
63
|
+
end
|
64
|
+
|
65
|
+
# Merge the scope's relation into the base relation.
|
66
|
+
# The `merge` method (currently a placeholder) is responsible
|
67
|
+
# for combining the Cyrel queries correctly.
|
68
|
+
base_relation.merge(scope_relation)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Defines the default scope for the model.
|
73
|
+
#
|
74
|
+
# The default scope is automatically applied to all queries for the model
|
75
|
+
# unless explicitly removed using `unscoped`.
|
76
|
+
#
|
77
|
+
# @param body [Proc] A lambda or proc defining the default scope conditions.
|
78
|
+
# It should return an ActiveCypher::Relation.
|
79
|
+
# @param block [Proc] Alternative way to pass the scope body as a block.
|
80
|
+
#
|
81
|
+
# @example
|
82
|
+
# class Post < ActiveCypher::Base
|
83
|
+
# default_scope -> { where(published: true) }
|
84
|
+
# end
|
85
|
+
#
|
86
|
+
# Post.all # Automatically applies `where(published: true)`
|
87
|
+
#
|
88
|
+
def default_scope(body = nil, &block)
|
89
|
+
body = block if block_given?
|
90
|
+
|
91
|
+
raise ArgumentError, 'The default scope body must be a Proc or lambda, or nil to remove.' unless body.nil? || body.is_a?(Proc)
|
92
|
+
|
93
|
+
self._default_scope = body
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|