activecypher 0.0.0 → 0.3.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 +61 -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 +16 -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/sig/activecypher.rbs +4 -0
- metadata +172 -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
|