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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/associations/collection_proxy.rb +144 -0
  3. data/lib/active_cypher/associations.rb +537 -0
  4. data/lib/active_cypher/base.rb +47 -0
  5. data/lib/active_cypher/bolt/connection.rb +525 -0
  6. data/lib/active_cypher/bolt/driver.rb +144 -0
  7. data/lib/active_cypher/bolt/handlers.rb +10 -0
  8. data/lib/active_cypher/bolt/message_reader.rb +100 -0
  9. data/lib/active_cypher/bolt/message_writer.rb +53 -0
  10. data/lib/active_cypher/bolt/messaging.rb +307 -0
  11. data/lib/active_cypher/bolt/packstream.rb +319 -0
  12. data/lib/active_cypher/bolt/result.rb +82 -0
  13. data/lib/active_cypher/bolt/session.rb +201 -0
  14. data/lib/active_cypher/bolt/transaction.rb +211 -0
  15. data/lib/active_cypher/bolt/version_encoding.rb +41 -0
  16. data/lib/active_cypher/bolt.rb +7 -0
  17. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
  18. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
  19. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  20. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
  21. data/lib/active_cypher/connection_factory.rb +130 -0
  22. data/lib/active_cypher/connection_handler.rb +9 -0
  23. data/lib/active_cypher/connection_pool.rb +123 -0
  24. data/lib/active_cypher/connection_url_resolver.rb +137 -0
  25. data/lib/active_cypher/cypher_config.rb +50 -0
  26. data/lib/active_cypher/generators/install_generator.rb +23 -0
  27. data/lib/active_cypher/generators/node_generator.rb +32 -0
  28. data/lib/active_cypher/generators/relationship_generator.rb +33 -0
  29. data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
  30. data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
  31. data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
  32. data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
  33. data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
  34. data/lib/active_cypher/logging.rb +44 -0
  35. data/lib/active_cypher/model/abstract.rb +87 -0
  36. data/lib/active_cypher/model/attributes.rb +24 -0
  37. data/lib/active_cypher/model/callbacks.rb +44 -0
  38. data/lib/active_cypher/model/connection_handling.rb +76 -0
  39. data/lib/active_cypher/model/connection_owner.rb +50 -0
  40. data/lib/active_cypher/model/core.rb +45 -0
  41. data/lib/active_cypher/model/countable.rb +30 -0
  42. data/lib/active_cypher/model/destruction.rb +49 -0
  43. data/lib/active_cypher/model/inspectable.rb +28 -0
  44. data/lib/active_cypher/model/persistence.rb +182 -0
  45. data/lib/active_cypher/model/querying.rb +67 -0
  46. data/lib/active_cypher/railtie.rb +34 -0
  47. data/lib/active_cypher/relation.rb +190 -0
  48. data/lib/active_cypher/relationship.rb +233 -0
  49. data/lib/active_cypher/runtime_registry.rb +8 -0
  50. data/lib/active_cypher/scoping.rb +97 -0
  51. data/lib/active_cypher/utils/logger.rb +100 -0
  52. data/lib/active_cypher/version.rb +5 -0
  53. data/lib/activecypher.rb +108 -0
  54. data/lib/cyrel/call_procedure.rb +29 -0
  55. data/lib/cyrel/clause/call.rb +46 -0
  56. data/lib/cyrel/clause/call_subquery.rb +40 -0
  57. data/lib/cyrel/clause/create.rb +33 -0
  58. data/lib/cyrel/clause/delete.rb +41 -0
  59. data/lib/cyrel/clause/limit.rb +33 -0
  60. data/lib/cyrel/clause/match.rb +40 -0
  61. data/lib/cyrel/clause/merge.rb +34 -0
  62. data/lib/cyrel/clause/order_by.rb +78 -0
  63. data/lib/cyrel/clause/remove.rb +75 -0
  64. data/lib/cyrel/clause/return.rb +90 -0
  65. data/lib/cyrel/clause/set.rb +97 -0
  66. data/lib/cyrel/clause/skip.rb +34 -0
  67. data/lib/cyrel/clause/where.rb +42 -0
  68. data/lib/cyrel/clause/with.rb +94 -0
  69. data/lib/cyrel/clause.rb +25 -0
  70. data/lib/cyrel/direction.rb +18 -0
  71. data/lib/cyrel/expression/alias.rb +27 -0
  72. data/lib/cyrel/expression/base.rb +101 -0
  73. data/lib/cyrel/expression/case.rb +45 -0
  74. data/lib/cyrel/expression/comparison.rb +60 -0
  75. data/lib/cyrel/expression/exists.rb +42 -0
  76. data/lib/cyrel/expression/function_call.rb +57 -0
  77. data/lib/cyrel/expression/literal.rb +33 -0
  78. data/lib/cyrel/expression/logical.rb +38 -0
  79. data/lib/cyrel/expression/operator.rb +27 -0
  80. data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
  81. data/lib/cyrel/expression/property_access.rb +25 -0
  82. data/lib/cyrel/expression.rb +56 -0
  83. data/lib/cyrel/functions.rb +116 -0
  84. data/lib/cyrel/node.rb +397 -0
  85. data/lib/cyrel/parameterizable.rb +20 -0
  86. data/lib/cyrel/pattern/node.rb +66 -0
  87. data/lib/cyrel/pattern/path.rb +41 -0
  88. data/lib/cyrel/pattern/relationship.rb +74 -0
  89. data/lib/cyrel/pattern.rb +8 -0
  90. data/lib/cyrel/query.rb +497 -0
  91. data/lib/cyrel/return_only.rb +26 -0
  92. data/lib/cyrel/types/hash_type.rb +22 -0
  93. data/lib/cyrel/types/symbol_type.rb +13 -0
  94. data/lib/cyrel.rb +72 -0
  95. data/lib/tasks/active_cypher_tasks.rake +6 -0
  96. data/sig/activecypher.rbs +4 -0
  97. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module RuntimeRegistry
5
+ thread_mattr_accessor :current_role, default: :writing
6
+ thread_mattr_accessor :current_shard, default: :default
7
+ end
8
+ 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