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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds `self.abstract_class = true` support, mimicking ActiveRecord’s
4
+ # approach to abstract base classes. No runtime enforcement—just vibes,
5
+ # a sprinkle of Ruby sorcery, and the hope you know what you're doing.
6
+ # Because nothing says "enterprise" like a flag that means "please ignore me."
7
+ #
8
+ # This module gives every subclass an `abstract_class` boolean,
9
+ # along with the `abstract_class?` reader. You can assign this flag
10
+ # in your base class to signal “do not instantiate me,” like a digital
11
+ # “Do Not Resuscitate” order. It's the ORM equivalent of a velvet rope at a nightclub,
12
+ # or a protective circle against accidental instantiation.
13
+ #
14
+ # === Example
15
+ #
16
+ # class ApplicationGraphNode < ActiveCypher::Base
17
+ # self.abstract_class = true
18
+ # end
19
+ #
20
+ # class PersonNode < ApplicationGraphNode
21
+ # attribute :name, :string
22
+ # end
23
+ #
24
+ # ApplicationGraphNode.abstract_class? # => true
25
+ # PersonNode.abstract_class? # => false
26
+ #
27
+ # Querying an abstract class will raise a runtime error—
28
+ # eventually. Maybe. Down in the adapter layer where your dreams go to die.
29
+ # Or at least where your stacktraces go to get longer. If only you had a talisman
30
+ # against such errors—perhaps.
31
+ #
32
+ module ActiveCypher
33
+ module Model
34
+ # @!parse
35
+ # # Adds support for marking a class as abstract, so you can feel important and uninstantiable.
36
+ # # No runtime enforcement—just vibes, a dash of Ruby witchcraft, and the hope you know what you're doing.
37
+ # # It's the ORM equivalent of a velvet rope at a nightclub, or a magical ward against instantiation.
38
+ module Abstract
39
+ extend ActiveSupport::Concern
40
+
41
+ included do
42
+ # Define a per-class flag for abstract status.
43
+ # Default is false, because chaos is opt-in.
44
+ class_attribute :abstract_class, instance_accessor: false, default: false
45
+
46
+ # Ensure subclasses are born concrete unless they say otherwise.
47
+ # Because every child deserves a chance to disappoint you in its own way.
48
+ # This is the Ruby equivalent of breaking the circle and letting the spirits loose.
49
+ def self.inherited(subclass)
50
+ super
51
+ subclass.abstract_class = false
52
+ end
53
+ end
54
+
55
+ class_methods do
56
+ # Sets whether this class is abstract.
57
+ #
58
+ # @param value [Boolean] true to mark the class as abstract.
59
+ # @return [void]
60
+ def abstract_class=(value)
61
+ self.abstract_class = !!value
62
+ end
63
+
64
+ # Checks if this class is abstract.
65
+ #
66
+ # @return [Boolean] true if the class is abstract.
67
+ def abstract_class? = abstract_class
68
+
69
+ # Override query methods to raise if called on an abstract class.
70
+ # Because nothing says “don’t do that” like a runtime exception.
71
+ # It's like a velvet rope for your ORM: "Sorry, you're not on the list."
72
+ # If you try to cross this boundary, beware: you may awaken ancient bugs
73
+ %i[all where limit order].each do |method|
74
+ undef_method method if method_defined?(method)
75
+ define_method(method) do |*args, **kw|
76
+ if abstract_class?
77
+ raise ActiveCypher::AbstractClassError,
78
+ "#{name} is abstract; `.#{method}` is not allowed. (But hey, at least you tried. Next time, bring a spellbook.)"
79
+ end
80
+
81
+ super(*args, **kw)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # @!parse
6
+ # # Attributes: Because every model needs a place to store its baggage.
7
+ # # Provides helpers for attribute persistence, so you can pretend your data is tidy.
8
+ # # Also, because every good ORM needs a little bit of witchcraft to make things “just work.”
9
+ # # If you see something working and can't explain why, it's probably quantum Ruby entanglement.
10
+ module Attributes
11
+ extend ActiveSupport::Concern
12
+
13
+ # Helpers
14
+ private
15
+
16
+ # Returns the attributes to be persisted, minus the internal_id.
17
+ # Because sometimes you just want to forget where you came from.
18
+ # @return [Hash] The attributes suitable for persistence
19
+ def attributes_for_persistence
20
+ attributes.except('internal_id').compact
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ # <= matches your other concerns
5
+ module Model
6
+ # @!parse
7
+ # # Model::Callbacks provides lifecycle hooks for your models, so you can pretend you have control over what happens and when.
8
+ # # Because nothing says "enterprise" like a callback firing at just the wrong moment.
9
+ # # Under the hood, it’s all just a little bit of Ruby sorcery, callback witchcraft, and the occasional forbidden incantation from the callback crypt.
10
+ module Callbacks
11
+ extend ActiveSupport::Concern
12
+
13
+ EVENTS = %i[
14
+ initialize find validate create update save destroy
15
+ ].freeze
16
+
17
+ included do
18
+ include ActiveSupport::Callbacks
19
+ define_callbacks(*EVENTS)
20
+ end
21
+
22
+ class_methods do
23
+ %i[before after around].each do |kind|
24
+ EVENTS.each do |evt|
25
+ define_method("#{kind}_#{evt}") do |*filters, &block|
26
+ # This is where the callback coven gathers to cast their hooks.
27
+ set_callback(evt, kind, *filters, &block)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Thin wrapper so models can do `_run(:create) { … }`
36
+ # Because sometimes you want to feel like you’re orchestrating fate.
37
+ # @param evt [Symbol] The callback event
38
+ # @yield Runs inside the callback chain
39
+ # @return [Object] The result of the block
40
+ # Warning: This method may summon side effects from the shadow realm, or just invoke a little callback necromancy when you least expect it.
41
+ def _run(evt) = run_callbacks(evt) { yield if block_given? }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # Handles connection logic for models, because every ORM needs a way to feel connected.
6
+ # @note All real connection magic is just Ruby sorcery, a dash of forbidden Ruby incantations, and a sprinkle of ActiveSupport witchcraft.
7
+ module ConnectionHandling
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Establishes a connection for the model.
12
+ # Because every model deserves a shot at disappointment.
13
+ # @note Under the hood, this is just Ruby sorcery and a little forbidden Ruby wizardry to make your config work.
14
+ def establish_connection(config)
15
+ cfg = config.symbolize_keys
16
+
17
+ # Handle both URL-based and traditional config
18
+ if cfg[:url]
19
+ # Use ConnectionUrlResolver for URL-based config
20
+ resolver = ActiveCypher::ConnectionUrlResolver.new(cfg[:url])
21
+ resolved_config = resolver.to_hash
22
+
23
+ # Merge any additional config options
24
+ resolved_config = resolved_config.merge(cfg.except(:url)) if resolved_config
25
+
26
+ # Bail if URL couldn't be parsed
27
+ raise ArgumentError, "Invalid connection URL: #{cfg[:url]}" unless resolved_config
28
+
29
+ # Get adapter name from resolved config
30
+ adapter_name = resolved_config[:adapter]
31
+ else
32
+ # Traditional config with explicit adapter
33
+ adapter_name = cfg[:adapter] or raise ArgumentError, 'Missing :adapter'
34
+ resolved_config = cfg
35
+ end
36
+
37
+ path = "active_cypher/connection_adapters/#{adapter_name}_adapter"
38
+ class_name = "#{adapter_name}_adapter".camelize
39
+
40
+ require path
41
+ adapter_class = ActiveCypher::ConnectionAdapters.const_get(class_name)
42
+ self.connection = adapter_class.new(resolved_config)
43
+ connection.connect
44
+ connection
45
+ rescue LoadError => e
46
+ raise AdapterLoadError, "Could not load ActiveCypher adapter '#{adapter_name}' (#{e.message})"
47
+ end
48
+
49
+ # Sets up multiple connections for different roles, because one pool is never enough.
50
+ # @param mapping [Hash] Role-to-database mapping
51
+ # @return [void]
52
+ # Sets up multiple connections for different roles, because one pool is never enough.
53
+ # @param mapping [Hash] Role-to-database mapping
54
+ # @return [void]
55
+ # @note This is where the Ruby gremlins really start dancing—multiple pools, one registry, and a sprinkle of connection witchcraft.
56
+ def connects_to(mapping)
57
+ mapping.deep_symbolize_keys.each do |role, db_key|
58
+ spec = ActiveCypher::CypherConfig.for(db_key) # ← may raise KeyError
59
+
60
+ # If spec contains a URL, use ConnectionFactory
61
+ if spec[:url]
62
+ factory = ActiveCypher::ConnectionFactory.new(spec[:url])
63
+ spec = factory.config.merge(spec.except(:url)) if factory.valid?
64
+ end
65
+
66
+ pool = ActiveCypher::ConnectionPool.new(spec)
67
+ connection_handler.set(role.to_sym, :default, pool)
68
+ rescue KeyError => e
69
+ raise ActiveCypher::UnknownConnectionError,
70
+ "connects_to #{role}: #{db_key.inspect} – #{e.message}"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # Mixin for anything that “owns” a connection (nodes, relationships, maybe
6
+ # graph‑level service objects later). 100 % framework‑agnostic.
7
+ # @note Because every object wants to feel important by "owning" something, even if it's just a connection.
8
+ # Moroccan black magick and Ruby sorcery may be involved in keeping these connections alive.
9
+ module ConnectionOwner
10
+ extend ActiveSupport::Concern
11
+ include ActiveCypher::Logging
12
+ include ActiveCypher::Model::ConnectionHandling
13
+
14
+ included do
15
+ # Every class gets its own adapter slot (overridden by establish_connection)
16
+ # Because nothing says "flexibility" like a class variable you'll forget exists.
17
+ # This is where the witchcraft happens: sometimes the right connection just appears.
18
+ cattr_accessor :connection, instance_accessor: false
19
+ end
20
+
21
+ class_methods do
22
+ delegate :current_role, :current_shard,
23
+ to: ActiveCypher::RuntimeRegistry
24
+
25
+ # One handler for all subclasses that include this concern
26
+ # Because sharing is caring, except when it comes to connection pools.
27
+ # Summoned by Ruby wizardry: this handler is conjured once and shared by all.
28
+ @@connection_handler ||= ActiveCypher::ConnectionHandler.new # rubocop:disable Style/ClassVars
29
+ def connection_handler = @@connection_handler
30
+
31
+ # Temporarily switches the current role and shard for the duration of the block.
32
+ # @param role [Symbol, nil] The role to switch to
33
+ # @param shard [Symbol] The shard to switch to
34
+ # @yield The block to execute with the new context
35
+ # @note Because sometimes you just want to pretend you're connected to something else for a while.
36
+ # Warning: If you switch too often, you may summon unexpected spirits from the Ruby shadow dimension.
37
+ def connected_to(role: nil, shard: :default)
38
+ previous_role = current_role
39
+ previous_shard = current_shard
40
+ ActiveCypher::RuntimeRegistry.current_role = role || previous_role
41
+ ActiveCypher::RuntimeRegistry.current_shard = shard || previous_shard
42
+ yield
43
+ ensure
44
+ ActiveCypher::RuntimeRegistry.current_role = previous_role
45
+ ActiveCypher::RuntimeRegistry.current_shard = previous_shard
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ module ActiveCypher
6
+ module Model
7
+ # @!parse
8
+ # # Core: The module that tries to make your graph model feel like it belongs in a relational world.
9
+ # # Includes every concern under the sun, because why have one abstraction when you can have twelve?
10
+ # # Most of this works thanks to a little Ruby sorcery, a dash of witchcraft, and—on rare occasions—some unexplained back magick.
11
+ module Core
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ include ActiveModel::API
16
+ include ActiveModel::Attributes
17
+ include ActiveModel::Dirty
18
+ include ActiveCypher::Associations
19
+ include ActiveCypher::Scoping
20
+ include ActiveModel::Validations
21
+
22
+ attribute :internal_id, :string
23
+
24
+ cattr_accessor :connection, instance_accessor: false
25
+ class_attribute :configurations, instance_accessor: false,
26
+ default: ActiveSupport::HashWithIndifferentAccess.new
27
+ end
28
+
29
+ attr_reader :new_record
30
+
31
+ # Initializes a new model instance, because every object deserves a fresh start (and a fresh set of existential crises).
32
+ #
33
+ # @param attributes [Hash] Attributes to assign to the new instance
34
+ # @note If this works and you can't explain why, it's probably back magick.
35
+ def initialize(attributes = {})
36
+ _run(:initialize) do # <-- callback wrapper
37
+ super()
38
+ assign_attributes(attributes.symbolize_keys) if attributes
39
+ @new_record = true # Always true for normal initialization, because innocence is fleeting
40
+ clear_changes_information
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # Mixin that gives any graph element (node or edge) an efficient `.count`
6
+ #
7
+ # It issues a single Cypher `COUNT()` query instead of loading rows.
8
+ # Because sometimes you just want to know how many regrets you have, without reliving each one.
9
+ # A little ORM sorcery, a dash of witchcraft, and—very rarely—some back magick make this possible.
10
+ module Countable
11
+ extend ActiveSupport::Concern
12
+
13
+ class_methods do
14
+ # @return [Integer] total rows for this label / rel‑type
15
+ # Because loading all the data just to count it is so last decade.
16
+ # If this returns the right number, thank the database gods—or maybe just the back magick hiding in the adapter.
17
+ def count
18
+ cypher, params =
19
+ if respond_to?(:label_name) # ⇒ node class
20
+ ["MATCH (n:#{label_name}) RETURN count(n) AS c", {}]
21
+ else # ⇒ relationship class
22
+ ["MATCH ()-[r:#{relationship_type}]-() RETURN count(r) AS c", {}] # ▲ undirected
23
+ end
24
+
25
+ connection.execute_cypher(cypher, params, 'Count').first[:c].to_i
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # @!parse
6
+ # # Destruction: The module that lets you banish records from existence with a single incantation.
7
+ # # Uses a blend of Ruby sorcery, a dash of witchcraft, and—on rare occasions—some back magick when nothing else will do.
8
+ module Destruction
9
+ extend ActiveSupport::Concern
10
+
11
+ # Deletes the record from the database. Permanently. No takesies-backsies.
12
+ #
13
+ # Runs a Cypher `DETACH DELETE` query on the node.
14
+ # Freezes the object to prevent further use, as a kind of ceremonial burial.
15
+ # Because nothing says "closure" like a frozen Ruby object.
16
+ # If this works and you can't explain why, that's probably back magick.
17
+ #
18
+ # @raise [RuntimeError] if the record is new or already destroyed.
19
+ # @return [Boolean] true if the record was successfully destroyed, false if something caught on fire.
20
+ def destroy
21
+ _run(:destroy) do
22
+ raise 'Cannot destroy a new record' if new_record?
23
+ raise 'Record already destroyed' if destroyed?
24
+
25
+ n = :n
26
+ query = Cyrel.match(Cyrel.node(self.class.label_name).as(n))
27
+ .where(Cyrel.id(n).eq(internal_id))
28
+ .detach_delete(n)
29
+
30
+ cypher = query.to_cypher
31
+ params = { id: internal_id }
32
+
33
+ # Here lies the true sorcery: one line to erase a node from existence.
34
+ # If the database still remembers it, you may need to consult your local witch.
35
+ self.class.connection.execute_cypher(cypher, params, 'Destroy')
36
+ @destroyed = true
37
+ freeze # To make sure you can't Frankenstein it back to life. Lightning not included.
38
+ true
39
+ end
40
+ rescue StandardError
41
+ false # Something went wrong. Don’t ask. Just walk away. Or blame the database, that's always fun. If it keeps happening, suspect back magick.
42
+ end
43
+
44
+ # Returns true if this object has achieved full existential closure.
45
+ # If you see this return true and the record still exists, that's not a bug—it's witchcraft.
46
+ def destroyed? = @destroyed == true
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # @!parse
6
+ # # Adds a custom inspect method for pretty-printing a compact, single-line summary of the object.
7
+ # # Because nothing says "debuggable" like a string that pretends your object is more interesting than it is.
8
+ module Inspectable
9
+ # Custom object inspection method for pretty-printing a compact,
10
+ # single-line summary of the object. Output examples:
11
+ #
12
+ # #<UserNode id="26" name="Alice" age=34> => persisted object
13
+ # #<UserNode (new) name="Bob"> => object not yet saved
14
+ #
15
+ def inspect
16
+ # Put 'internal_id' first like it's the main character (even if it's nil)
17
+ ordered = attributes.dup
18
+ ordered = ordered.slice('internal_id').merge(ordered.except('internal_id'))
19
+
20
+ # Turn each attr into "key: value" because we humans fear raw hashes
21
+ parts = ordered.map { |k, v| "#{k}: #{v.inspect}" }
22
+
23
+ # Wrap it all up in a fake-sane object string, so you can pretend your data is organized.
24
+ "#<#{self.class} #{parts.join(', ')}>"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ # @!parse
6
+ # # Persistence: Because your data deserves a second chance, even if you don't.
7
+ # # A little ORM sorcery, a dash of witchcraft, and—on rare occasions—some unexplained back magick.
8
+ module Persistence
9
+ extend ActiveSupport::Concern
10
+
11
+ # Saves the current record to the database.
12
+ #
13
+ # If it's a new record, it's born into this cruel digital world.
14
+ # If it already exists, we patch up its regrets.
15
+ # If it fails, we return false, like cowards.
16
+ #
17
+ # @return [Boolean] true if saved successfully, false if the database ghosted us.
18
+ # Because nothing says "robust" like pretending persistence is easy.
19
+ # If this works and you can't explain why, that's probably back magick.
20
+ def save
21
+ # before_/after_create
22
+ _run(:save) do
23
+ if new_record?
24
+ _run(:create) { create_record }
25
+ else
26
+ _run(:update) { update_record }
27
+ end
28
+ end
29
+ rescue RecordNotSaved
30
+ false
31
+ end
32
+
33
+ # Updates the record's attributes and then saves it.
34
+ #
35
+ # You know, like hope. But with hash keys.
36
+ # Because nothing says "optimism" like update-in-place.
37
+ # If this method ever fixes a bug you couldn't reproduce, that's just ORM witchcraft.
38
+ #
39
+ # @param attrs [Hash] the attributes to assign.
40
+ # @return [Boolean] true if we pretended hard enough to update.
41
+ def update(attrs)
42
+ assign_attributes(attrs)
43
+ save
44
+ end
45
+
46
+ # Reloads the record from the database and overwrites your foolish edits.
47
+ #
48
+ # Great for when you want to feel powerless again.
49
+ # Because sometimes you just want to see your changes disappear.
50
+ # If this ever resurrects data you thought was lost, that's not a bug—it's back magick.
51
+ #
52
+ # @raise [ActiveCypher::RecordNotFound] if the record is missing. Like your confidence.
53
+ # @return [self] the refreshed version of yourself, now with 30% more doubt.
54
+ def reload
55
+ raise ActiveCypher::RecordNotFound, 'Record not persisted' if new_record?
56
+
57
+ fresh = self.class.find(internal_id)
58
+ unless fresh
59
+ raise ActiveCypher::RecordNotFound,
60
+ "#{self.class} with internal_id=#{internal_id.inspect} not found"
61
+ end
62
+
63
+ self.attributes = fresh.attributes
64
+ clear_changes_information
65
+ self
66
+ end
67
+
68
+ # Returns true if the record is new and untouched by the database.
69
+ #
70
+ # @return [Boolean] true if the record is innocent.
71
+ # If this ever returns true for a record you thought was persisted, consult your local sorcerer.
72
+ def new_record? = @new_record
73
+
74
+ # Returns true if the record has been saved and now bears a scar (internal_id).
75
+ #
76
+ # @return [Boolean] true if the record has a past.
77
+ # If this ever returns false for a record you see in the database, that's pure ORM sorcery.
78
+ def persisted? = !new_record? && internal_id.present?
79
+
80
+ class_methods do
81
+ # Factory method for instantiating records from the database.
82
+ # Because sometimes you want to skip the whole "life cycle" thing.
83
+ # If this ever returns an object that shouldn't exist, that's back magick at work.
84
+ #
85
+ # @param attributes [Hash] Attributes from the database
86
+ # @return [ActiveCypher::Base, ActiveCypher::Relationship] A record marked as persisted
87
+ def instantiate(attributes)
88
+ rec = new # bootstrap
89
+ rec.assign_attributes(attributes)
90
+ rec.instance_variable_set(:@new_record, false)
91
+ rec.clear_changes_information # nothing is “dirty”
92
+ rec
93
+ end
94
+
95
+ # Bang‑version of `.create` — raises if the record can't be persisted.
96
+ # For when you want your errors loud and proud.
97
+ # If this ever succeeds when it shouldn't, that's not a feature—it's back magick.
98
+ #
99
+ # @param attrs [Hash]
100
+ # @return [ActiveCypher::Base] persisted record
101
+ # @raise [ActiveCypher::RecordNotSaved]
102
+ def create!(attrs = {})
103
+ rec = create(attrs)
104
+ if rec.persisted?
105
+ rec
106
+ else
107
+ raise ActiveCypher::RecordNotSaved,
108
+ "#{name} could not be saved: #{rec.errors.full_messages.join(', ')}"
109
+ end
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # # Internal method to initialize a record from the database
116
+ # # @param attributes [Hash] The attributes from the database
117
+ # # @return [self]
118
+ # def init_with_attributes(attrs)
119
+ # binding.irb
120
+ # assign_attributes(**attrs) if attrs
121
+ # @new_record = false # Mark as not new when loading from DB
122
+ # clear_changes_information
123
+ # self
124
+ # end
125
+
126
+ # Creates the record in the database using Cypher.
127
+ #
128
+ # @return [Boolean] true if the database accepted your offering.
129
+ # Because nothing says "production ready" like a hand-crafted query.
130
+ # If this method ever works on the first try, that's not engineering—it's back magick.
131
+ def create_record
132
+ props = attributes_for_persistence
133
+ n = :n
134
+ label = self.class.label_name.to_s
135
+ node = Cyrel.node(n, labels: [label], properties: props)
136
+ query = Cyrel.create(node).return_(Cyrel.element_id(n).as(:internal_id))
137
+ cypher, params = query.to_cypher
138
+ params ||= {}
139
+
140
+ data = self.class.connection.execute_cypher(cypher, params, 'Create')
141
+ return false if data.blank? || !data.first.key?(:internal_id)
142
+
143
+ self.internal_id = data.first[:internal_id].to_s
144
+ @new_record = false
145
+ changes_applied
146
+ true
147
+ end
148
+
149
+ # Returns a hash of attributes that have changed and their spicy new values.
150
+ #
151
+ # @return [Hash] the things you dared to modify.
152
+ # Because tracking regret is what ORMs do best. If this ever returns an empty hash when you know you changed something, that's just ORM sorcery.
153
+ def changes_to_save
154
+ changes.transform_values(&:last)
155
+ end
156
+
157
+ # Updates the record in the database using Cypher, if anything changed.
158
+ #
159
+ # If nothing changed, we lie about doing work and return true anyway.
160
+ # Because sometimes you just want to feel productive.
161
+ # If this method ever updates the database when nothing changed, that's not a bug—it's back magick.
162
+ #
163
+ # @return [Boolean] true if we updated something, or just acted like we did.
164
+ def update_record
165
+ changes = changes_to_save
166
+ return true if changes.empty?
167
+
168
+ n = :n
169
+ query = Cyrel.match(Cyrel.node(self.class.label_name).as(n))
170
+ .where(Cyrel.id(n).eq(internal_id))
171
+ .set(n => changes)
172
+
173
+ cypher, params = query.to_cypher
174
+ params ||= {}
175
+
176
+ self.class.connection.execute_cypher(cypher, params, 'Update')
177
+ changes_applied
178
+ true
179
+ end
180
+ end
181
+ end
182
+ end