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,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'singleton'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
module Utils
|
8
|
+
# A singleton logger class for ActiveCypher
|
9
|
+
class Logger
|
10
|
+
include Singleton
|
11
|
+
|
12
|
+
attr_accessor :logger
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@logger = ::Logger.new($stdout)
|
16
|
+
@logger.level = ::Logger::INFO
|
17
|
+
@logger.formatter = self.class.standard_formatter
|
18
|
+
end
|
19
|
+
|
20
|
+
# Configure the logger
|
21
|
+
# @param options [Hash] Configuration options
|
22
|
+
# @option options [IO] :output Where to send logs (default: $stdout)
|
23
|
+
# @option options [Symbol, Integer] :level Log level (default: :info)
|
24
|
+
# @option options [Proc] :formatter Custom formatter for log messages
|
25
|
+
def configure(options = {})
|
26
|
+
@logger = ::Logger.new(options[:output]) if options[:output]
|
27
|
+
|
28
|
+
if options[:level]
|
29
|
+
level = options[:level]
|
30
|
+
level = ::Logger.const_get(level.to_s.upcase) if level.is_a?(Symbol)
|
31
|
+
@logger.level = level
|
32
|
+
end
|
33
|
+
|
34
|
+
@logger.formatter = options[:formatter] if options[:formatter]
|
35
|
+
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
# Logger delegation methods
|
40
|
+
%i[debug info warn error fatal].each do |level|
|
41
|
+
define_method(level) do |message|
|
42
|
+
@logger.send(level, message)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Define class methods that delegate to the instance
|
46
|
+
define_singleton_method(level) do |message|
|
47
|
+
instance.send(level, message)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Get the current logger level
|
52
|
+
# @return [Integer] Current log level
|
53
|
+
def level
|
54
|
+
@logger.level
|
55
|
+
end
|
56
|
+
|
57
|
+
# Set the logger level
|
58
|
+
# @param level [Symbol, Integer] The log level
|
59
|
+
def level=(level)
|
60
|
+
level = ::Logger.const_get(level.to_s.upcase) if level.is_a?(Symbol)
|
61
|
+
@logger.level = level
|
62
|
+
end
|
63
|
+
|
64
|
+
# Class methods that delegate to the instance
|
65
|
+
class << self
|
66
|
+
def configure(options = {})
|
67
|
+
instance.configure(options)
|
68
|
+
end
|
69
|
+
|
70
|
+
def level
|
71
|
+
instance.level
|
72
|
+
end
|
73
|
+
|
74
|
+
def level=(level)
|
75
|
+
instance.level = level
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns a standard formatter with time stamp
|
79
|
+
# @return [Proc] A standard log formatter
|
80
|
+
def standard_formatter
|
81
|
+
proc do |severity, time, _progname, msg|
|
82
|
+
time_str = time.strftime('%H:%M:%S.%L')
|
83
|
+
"[#{time_str}] #{severity}: #{msg}\n"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Setup with standard configuration for all examples
|
88
|
+
# @return [self]
|
89
|
+
def setup
|
90
|
+
configure(formatter: standard_formatter)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Convenience method for accessing the logger
|
98
|
+
def logger
|
99
|
+
ActiveCypher::Utils::Logger.instance.logger
|
100
|
+
end
|
data/lib/activecypher.rb
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support'
|
4
|
+
require 'zeitwerk'
|
5
|
+
require_relative 'cyrel'
|
6
|
+
require_relative 'active_cypher/version'
|
7
|
+
|
8
|
+
# ActiveCypher is a Ruby gem that provides an ActiveRecord-like interface for
|
9
|
+
# interacting with Neo4j databases using Cypher queries.
|
10
|
+
|
11
|
+
module ActiveCypher
|
12
|
+
# Base error class. Rescue this if you're ready to admit something went wrong,
|
13
|
+
# but you're too emotionally unavailable to care what exactly it was.
|
14
|
+
class Error < StandardError; end
|
15
|
+
# For when you configured something wrong.
|
16
|
+
# A gentle reminder that "boot time" is also "blame time."
|
17
|
+
class ConfigurationError < Error; end
|
18
|
+
|
19
|
+
# You tried to use an adapter that doesn’t exist.
|
20
|
+
# It's not ghosting if it was never real to begin with.
|
21
|
+
class AdapterNotFoundError < ConfigurationError; end
|
22
|
+
|
23
|
+
# You found the adapter, but loading it failed.
|
24
|
+
# Like plugging in a toaster and discovering it's full of bees.
|
25
|
+
class AdapterLoadError < ConfigurationError; end
|
26
|
+
|
27
|
+
# You specified an environment that only exists in your imagination.
|
28
|
+
# Not everyone gets to be 'production', Brad.
|
29
|
+
class UnknownEnvironmentError < ConfigurationError; end
|
30
|
+
|
31
|
+
# The connection string is a lie.
|
32
|
+
# It promised connectivity. It delivered chaos.
|
33
|
+
class UnknownConnectionError < ConfigurationError; end
|
34
|
+
|
35
|
+
# Something went wrong in the connection layer.
|
36
|
+
# Possibly sabotage. Possibly just your code.
|
37
|
+
class ConnectionError < Error; end
|
38
|
+
|
39
|
+
# You never established a connection, and yet here you are—
|
40
|
+
# trying to ask the database for stuff like it owes you rent.
|
41
|
+
class ConnectionNotEstablished < ConnectionError; end
|
42
|
+
|
43
|
+
# The connection timed out. The database waited. It hoped. It gave up.
|
44
|
+
class ConnectionTimeoutError < ConnectionError; end
|
45
|
+
|
46
|
+
# The protocol is broken. Not socially — technically.
|
47
|
+
# Although, honestly, maybe both.
|
48
|
+
class ProtocolError < ConnectionError; end
|
49
|
+
|
50
|
+
# Something exploded during a query.
|
51
|
+
# Could be you. Could be Cypher. Could be fate.
|
52
|
+
class QueryError < Error; end
|
53
|
+
|
54
|
+
# Your Cypher syntax is... interpretive.
|
55
|
+
# Unfortunately, the parser isn’t in the mood for interpretive dance.
|
56
|
+
class CypherSyntaxError < QueryError; end
|
57
|
+
|
58
|
+
# The record you tried to find doesn’t exist.
|
59
|
+
# It heard you were coming and left.
|
60
|
+
class RecordNotFound < QueryError; end
|
61
|
+
|
62
|
+
# Your transaction failed to commit.
|
63
|
+
# So did your hopes and dreams.
|
64
|
+
class TransactionError < QueryError; end
|
65
|
+
|
66
|
+
# Persistence went wrong.
|
67
|
+
# The data tried to stay, but it just couldn't handle the pressure.
|
68
|
+
class PersistenceError < Error; end
|
69
|
+
|
70
|
+
# You tried to save a record, but it ghosted you mid-write.
|
71
|
+
class RecordNotSaved < PersistenceError; end
|
72
|
+
|
73
|
+
# You tried to destroy a record, but it clung to life.
|
74
|
+
# Congratulations. You discovered data with survival instincts.
|
75
|
+
class RecordNotDestroyed < PersistenceError; end
|
76
|
+
|
77
|
+
# Something failed validation.
|
78
|
+
# Probably your logic. Possibly your entire existence.
|
79
|
+
class ValidationError < PersistenceError; end
|
80
|
+
|
81
|
+
# You tried to instantiate an abstract class.
|
82
|
+
# That’s not just bad practice—it’s metaphysically wrong.
|
83
|
+
class AbstractClassError < PersistenceError; end
|
84
|
+
|
85
|
+
# Something went wrong with an association.
|
86
|
+
# Relationships are hard—even for nodes.
|
87
|
+
class AssociationError < Error; end
|
88
|
+
|
89
|
+
# You associated two things that really shouldn’t be talking.
|
90
|
+
# Stop trying to make fetch happen.
|
91
|
+
class AssociationTypeMismatch < AssociationError; end
|
92
|
+
|
93
|
+
# You messed up a has_many :through.
|
94
|
+
# Welcome to the Bermuda Triangle of ORM logic.
|
95
|
+
class HasManyThroughError < AssociationError; end
|
96
|
+
end
|
97
|
+
|
98
|
+
loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
99
|
+
loader.ignore("#{__dir__}/active_cypher/version.rb")
|
100
|
+
loader.ignore("#{__dir__}/active_cypher/railtie.rb")
|
101
|
+
loader.ignore("#{__dir__}/active_cypher/generators")
|
102
|
+
loader.ignore("#{__dir__}/activecypher.rb")
|
103
|
+
loader.ignore("#{__dir__}/cyrel.rb")
|
104
|
+
loader.inflector.inflect('activecypher' => 'ActiveCypher')
|
105
|
+
loader.push_dir("#{__dir__}/cyrel", namespace: Cyrel)
|
106
|
+
loader.setup
|
107
|
+
|
108
|
+
require 'active_cypher/railtie' if defined?(Rails)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
# Class for handling CALL procedures
|
5
|
+
class CallProcedure
|
6
|
+
def initialize(procedure)
|
7
|
+
@procedure = procedure
|
8
|
+
@yield_fields = []
|
9
|
+
@return_fields = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def yield(*fields)
|
13
|
+
@yield_fields = fields
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
def return(*fields)
|
18
|
+
@return_fields = fields
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_cypher
|
23
|
+
parts = ["CALL #{@procedure}()"]
|
24
|
+
parts << "YIELD #{@yield_fields.join(', ')}" if @yield_fields.any?
|
25
|
+
parts << "RETURN #{@return_fields.join(', ')}" if @return_fields.any?
|
26
|
+
parts.join(' ')
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a standalone CALL procedure clause.
|
6
|
+
# Example: CALL db.labels() YIELD label WHERE label STARTS WITH 'X' RETURN label
|
7
|
+
class Call < Base
|
8
|
+
attr_reader :procedure_name, :arguments, :yield_items, :where_clause, :return_clause
|
9
|
+
|
10
|
+
# @param procedure_name [String] The name of the procedure (e.g., "db.labels").
|
11
|
+
# @param arguments [Array] Arguments to pass to the procedure (will be parameterized).
|
12
|
+
# @param yield_items [Array<String>, String, nil] Raw string(s) for the YIELD part (e.g., "label", ["id", "name AS nodeName"]).
|
13
|
+
# @param where_clause [Cyrel::Clause::Where, nil] Optional WHERE clause applied after YIELD.
|
14
|
+
# @param return_clause [Cyrel::Clause::Return, nil] Optional RETURN clause applied after WHERE/YIELD.
|
15
|
+
def initialize(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil)
|
16
|
+
@procedure_name = procedure_name
|
17
|
+
@arguments = arguments # Store raw arguments, parameterize during render
|
18
|
+
@yield_items = yield_items ? Array(yield_items).join(', ') : nil # Simple string join for now
|
19
|
+
|
20
|
+
@where_clause = case where
|
21
|
+
when Clause::Where then where
|
22
|
+
when nil then nil
|
23
|
+
else Clause::Where.new(*Array(where)) # Coerce Hash/Array/Expression
|
24
|
+
end
|
25
|
+
|
26
|
+
@return_clause = case return_items
|
27
|
+
when Clause::Return then return_items
|
28
|
+
when nil then nil
|
29
|
+
else Clause::Return.new(*Array(return_items))
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def render(query)
|
34
|
+
rendered_args = @arguments.map { |arg| Expression.coerce(arg).render(query) }.join(', ')
|
35
|
+
call_part = "CALL #{@procedure_name}(#{rendered_args})"
|
36
|
+
yield_part = @yield_items ? " YIELD #{@yield_items}" : ''
|
37
|
+
where_part = @where_clause ? " #{@where_clause.render(query)}" : '' # Render WHERE clause
|
38
|
+
return_part = @return_clause ? " #{@return_clause.render(query)}" : '' # Render RETURN clause
|
39
|
+
|
40
|
+
# Ensure correct ordering and spacing
|
41
|
+
parts = [call_part, yield_part, where_part, return_part]
|
42
|
+
parts.compact.reject(&:empty?).join # Join non-empty parts without extra spaces
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a CALL { subquery } clause.
|
6
|
+
class CallSubquery < Base
|
7
|
+
attr_reader :subquery
|
8
|
+
|
9
|
+
# @param subquery [Cyrel::Query] The nested query object.
|
10
|
+
def initialize(subquery)
|
11
|
+
super() # Call super for Base initialization
|
12
|
+
raise ArgumentError, "Subquery must be a Cyrel::Query instance, got #{subquery.class}" unless subquery.is_a?(Cyrel::Query)
|
13
|
+
|
14
|
+
@subquery = subquery
|
15
|
+
end
|
16
|
+
|
17
|
+
# Renders the CALL { subquery } clause.
|
18
|
+
# Note: Parameter merging between outer and inner queries needs careful handling.
|
19
|
+
# This basic implementation assumes parameters are managed separately or
|
20
|
+
# the outer query's #merge! handles parameter transfer if the subquery
|
21
|
+
# was built and then passed in.
|
22
|
+
# @param _outer_query [Cyrel::Query] The outer query object (potentially needed for parameter context).
|
23
|
+
# @return [String] The Cypher string fragment for the clause.
|
24
|
+
def render(_outer_query)
|
25
|
+
# We need the subquery's cypher string and parameters.
|
26
|
+
# Parameters from the subquery need to be merged into the outer query.
|
27
|
+
# This is complex. For now, let's assume parameters are handled externally
|
28
|
+
# or the subquery doesn't introduce new parameters conflicting with the outer scope.
|
29
|
+
# A more robust solution would involve the outer query managing all parameters.
|
30
|
+
|
31
|
+
sub_cypher, _sub_params = @subquery.to_cypher
|
32
|
+
|
33
|
+
indented_sub_cypher = sub_cypher.gsub(/^/, ' ') # Indent subquery
|
34
|
+
"CALL {\n#{indented_sub_cypher}\n}" # Construct final string
|
35
|
+
|
36
|
+
# Return final string
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a CREATE clause in a Cypher query.
|
6
|
+
class Create < Base
|
7
|
+
attr_reader :pattern
|
8
|
+
|
9
|
+
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
10
|
+
# The pattern to create. Typically a Path or Node.
|
11
|
+
def initialize(pattern)
|
12
|
+
# Ensure pattern is a valid type for CREATE
|
13
|
+
unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
|
14
|
+
raise ArgumentError,
|
15
|
+
"CREATE pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
16
|
+
end
|
17
|
+
|
18
|
+
# NOTE: Creating relationships between existing nodes requires coordination.
|
19
|
+
# The pattern itself should reference existing aliases defined in a preceding MATCH/MERGE.
|
20
|
+
# The Query object might need to track defined aliases if validation is needed here.
|
21
|
+
@pattern = pattern
|
22
|
+
end
|
23
|
+
|
24
|
+
# Renders the CREATE clause.
|
25
|
+
# @param query [Cyrel::Query] The query object for rendering the pattern.
|
26
|
+
# @return [String] The Cypher string fragment for the clause.
|
27
|
+
def render(query)
|
28
|
+
pattern_string = @pattern.render(query)
|
29
|
+
"CREATE #{pattern_string}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a DELETE or DETACH DELETE clause in a Cypher query.
|
6
|
+
class Delete < Base
|
7
|
+
attr_reader :variables, :detach
|
8
|
+
|
9
|
+
# Initializes a DELETE clause.
|
10
|
+
# @param variables [Array<Symbol, String>] An array of variable names (aliases) to delete.
|
11
|
+
# @param detach [Boolean] Whether to use DETACH DELETE.
|
12
|
+
def initialize(*variables, detach: false)
|
13
|
+
@variables = variables.flatten.map(&:to_sym)
|
14
|
+
@detach = detach
|
15
|
+
raise ArgumentError, 'DELETE clause requires at least one variable.' if @variables.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
# Renders the DELETE or DETACH DELETE clause.
|
19
|
+
# @param _query [Cyrel::Query] The query object (unused for DELETE).
|
20
|
+
# @return [String] The Cypher string fragment for the clause.
|
21
|
+
def render(_query)
|
22
|
+
keyword = @detach ? 'DETACH DELETE' : 'DELETE'
|
23
|
+
variable_list = @variables.join(', ')
|
24
|
+
"#{keyword} #{variable_list}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Merges variables from another Delete clause.
|
28
|
+
# Note: Merging DETACH and non-DETACH might require specific rules.
|
29
|
+
# This implementation simply combines variables and uses the `detach`
|
30
|
+
# status of the clause being merged into. A more robust implementation
|
31
|
+
# might raise an error or prioritize DETACH if either is true.
|
32
|
+
# @param other_delete [Cyrel::Clause::Delete] The other Delete clause to merge.
|
33
|
+
def merge!(other_delete)
|
34
|
+
@variables.concat(other_delete.variables).uniq!
|
35
|
+
# Decide on detach status - let's prioritize detach=true if either has it
|
36
|
+
@detach ||= other_delete.detach
|
37
|
+
self
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a LIMIT clause in a Cypher query.
|
6
|
+
class Limit < Base
|
7
|
+
attr_reader :amount
|
8
|
+
|
9
|
+
# Initializes a LIMIT clause.
|
10
|
+
# @param amount [Integer, Cyrel::Expression::Base, Object]
|
11
|
+
# The maximum number of results to return. Can be an integer literal or an expression
|
12
|
+
# that evaluates to an integer (typically a parameter).
|
13
|
+
def initialize(amount)
|
14
|
+
@amount = Expression.coerce(amount)
|
15
|
+
# Could add validation here.
|
16
|
+
end
|
17
|
+
|
18
|
+
# Renders the LIMIT clause.
|
19
|
+
# @param query [Cyrel::Query] The query object for rendering the amount expression.
|
20
|
+
# @return [String] The Cypher string fragment for the clause.
|
21
|
+
def render(query)
|
22
|
+
"LIMIT #{@amount.render(query)}"
|
23
|
+
end
|
24
|
+
|
25
|
+
# Merging LIMIT typically replaces the existing value.
|
26
|
+
# @param other_limit [Cyrel::Clause::Limit] The other Limit clause.
|
27
|
+
def replace!(other_limit)
|
28
|
+
@amount = other_limit.amount
|
29
|
+
self
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a MATCH or OPTIONAL MATCH clause in a Cypher query.
|
6
|
+
class Match < Base
|
7
|
+
attr_reader :pattern, :optional, :path_variable
|
8
|
+
|
9
|
+
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
10
|
+
# The pattern to match. Typically a Path, but could be a single Node for simple matches.
|
11
|
+
# @param optional [Boolean] Whether this is an OPTIONAL MATCH.
|
12
|
+
# @param path_variable [Symbol, String, nil] An optional variable to assign to the matched path.
|
13
|
+
def initialize(pattern, optional: false, path_variable: nil)
|
14
|
+
super() # Call super for Base initialization
|
15
|
+
# Ensure pattern is a valid type
|
16
|
+
unless pattern.is_a?(Cyrel::Pattern::Path) ||
|
17
|
+
pattern.is_a?(Cyrel::Pattern::Node) ||
|
18
|
+
pattern.is_a?(Cyrel::Pattern::Relationship)
|
19
|
+
raise ArgumentError,
|
20
|
+
"Match pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
21
|
+
end
|
22
|
+
|
23
|
+
@pattern = pattern
|
24
|
+
@optional = optional
|
25
|
+
@path_variable = path_variable&.to_sym
|
26
|
+
end
|
27
|
+
|
28
|
+
# Renders the MATCH or OPTIONAL MATCH clause.
|
29
|
+
# @param query [Cyrel::Query] The query object for rendering the pattern.
|
30
|
+
# @return [String] The Cypher string fragment for the clause.
|
31
|
+
def render(query)
|
32
|
+
keyword = @optional ? 'OPTIONAL MATCH' : 'MATCH'
|
33
|
+
path_assignment = @path_variable ? "#{@path_variable} = " : ''
|
34
|
+
pattern_string = @pattern.render(query)
|
35
|
+
|
36
|
+
"#{keyword} #{path_assignment}#{pattern_string}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a MERGE clause in a Cypher query.
|
6
|
+
# Used to find a pattern or create it if it doesn't exist.
|
7
|
+
class Merge < Base
|
8
|
+
attr_reader :pattern
|
9
|
+
|
10
|
+
# TODO: Add support for ON CREATE SET and ON MATCH SET if needed later.
|
11
|
+
|
12
|
+
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
13
|
+
# The pattern to merge. Typically a Path or Node.
|
14
|
+
def initialize(pattern)
|
15
|
+
# Ensure pattern is a valid type for MERGE
|
16
|
+
unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
|
17
|
+
raise ArgumentError,
|
18
|
+
"MERGE pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
19
|
+
end
|
20
|
+
|
21
|
+
@pattern = pattern
|
22
|
+
end
|
23
|
+
|
24
|
+
# Renders the MERGE clause.
|
25
|
+
# @param query [Cyrel::Query] The query object for rendering the pattern.
|
26
|
+
# @return [String] The Cypher string fragment for the clause.
|
27
|
+
def render(query)
|
28
|
+
pattern_string = @pattern.render(query)
|
29
|
+
"MERGE #{pattern_string}"
|
30
|
+
# TODO: Append ON CREATE SET / ON MATCH SET rendering when implemented.
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents an ORDER BY clause in a Cypher query.
|
6
|
+
class OrderBy < Base
|
7
|
+
attr_reader :order_items
|
8
|
+
|
9
|
+
# Initializes an ORDER BY clause.
|
10
|
+
# @param order_items [Array<Array>]
|
11
|
+
# An array of arrays, where each inner array contains:
|
12
|
+
# [expression, direction]
|
13
|
+
# - expression: The expression to order by (coerced).
|
14
|
+
# - direction: :asc or :desc (Symbol or String).
|
15
|
+
# e.g., [[Cyrel.prop(:n, :age), :desc], ["name", :asc]]
|
16
|
+
def initialize(*order_items)
|
17
|
+
@order_items = process_items(order_items) # Process the array of pairs directly
|
18
|
+
raise ArgumentError, 'ORDER BY clause requires at least one order item.' if @order_items.empty?
|
19
|
+
end
|
20
|
+
|
21
|
+
# Renders the ORDER BY clause.
|
22
|
+
# @param query [Cyrel::Query] The query object for rendering expressions.
|
23
|
+
# @return [String] The Cypher string fragment for the clause.
|
24
|
+
def render(query)
|
25
|
+
rendered_items = @order_items.map do |item|
|
26
|
+
expression, direction = item
|
27
|
+
rendered_expr = render_expression(expression, query)
|
28
|
+
"#{rendered_expr} #{direction.to_s.upcase}"
|
29
|
+
end.join(', ')
|
30
|
+
|
31
|
+
"ORDER BY #{rendered_items}"
|
32
|
+
end
|
33
|
+
|
34
|
+
# Merging ORDER BY typically replaces the existing order.
|
35
|
+
# @param other_order_by [Cyrel::Clause::OrderBy] The other OrderBy clause.
|
36
|
+
def replace!(other_order_by)
|
37
|
+
@order_items = other_order_by.order_items
|
38
|
+
self
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def process_items(items)
|
44
|
+
items.map do |item|
|
45
|
+
unless item.is_a?(Array) && item.length == 2
|
46
|
+
raise ArgumentError, "Invalid ORDER BY item format. Expected [expression, :asc/:desc], got #{item.inspect}"
|
47
|
+
end
|
48
|
+
|
49
|
+
expression, direction = item
|
50
|
+
dir_sym = direction.to_s.downcase.to_sym
|
51
|
+
raise ArgumentError, "Invalid ORDER BY direction: #{direction}. Use :asc or :desc." unless %i[asc desc].include?(dir_sym)
|
52
|
+
|
53
|
+
[process_expression(expression), dir_sym]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Handles coercing the expression part of an order item.
|
58
|
+
def process_expression(expression)
|
59
|
+
case expression
|
60
|
+
when Expression::Base
|
61
|
+
expression
|
62
|
+
when Symbol, String
|
63
|
+
# Assume variable or simple property access string
|
64
|
+
Return::RawIdentifier.new(expression.to_s) # Reuse from Return
|
65
|
+
else
|
66
|
+
Expression.coerce(expression)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Renders the expression part of an order item.
|
71
|
+
def render_expression(expression, query)
|
72
|
+
if expression.is_a?(Return::RawIdentifier)
|
73
|
+
end
|
74
|
+
expression.render(query)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Clause
|
5
|
+
# Represents a REMOVE clause in a Cypher query.
|
6
|
+
# Used for removing properties or labels from nodes/relationships.
|
7
|
+
class Remove < Base
|
8
|
+
attr_reader :items
|
9
|
+
|
10
|
+
# Initializes a REMOVE clause.
|
11
|
+
# @param items [Array<Cyrel::Expression::PropertyAccess, Array>]
|
12
|
+
# An array containing items to remove:
|
13
|
+
# - PropertyAccess objects: e.g., Cyrel.prop(:n, :age)
|
14
|
+
# - Label specifications: e.g., [:n, "OldLabel"]
|
15
|
+
# @param items [Array<Cyrel::Expression::PropertyAccess, Array>] The items to remove.
|
16
|
+
def initialize(items)
|
17
|
+
@items = process_items(items) # Remove flatten, expect correct array structure
|
18
|
+
end
|
19
|
+
|
20
|
+
# Renders the REMOVE clause.
|
21
|
+
# @param query [Cyrel::Query] The query object (used for rendering property access if needed, though unlikely).
|
22
|
+
# @return [String, nil] The Cypher string fragment, or nil if no items to remove.
|
23
|
+
def render(query)
|
24
|
+
return nil if @items.empty?
|
25
|
+
|
26
|
+
remove_parts = @items.map do |item|
|
27
|
+
render_item(item, query)
|
28
|
+
end
|
29
|
+
|
30
|
+
"REMOVE #{remove_parts.join(', ')}"
|
31
|
+
end
|
32
|
+
|
33
|
+
# Merges items from another Remove clause.
|
34
|
+
# @param other_remove [Cyrel::Clause::Remove] The other Remove clause to merge.
|
35
|
+
def merge!(other_remove)
|
36
|
+
# Simple concatenation, assumes no duplicate removals.
|
37
|
+
@items.concat(other_remove.items)
|
38
|
+
self
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def process_items(items)
|
44
|
+
items.map do |item|
|
45
|
+
case item
|
46
|
+
when Expression::PropertyAccess
|
47
|
+
# Remove property: n.prop
|
48
|
+
[:property, item]
|
49
|
+
when Array
|
50
|
+
unless item.length == 2 && item[0].is_a?(Symbol) && item[1].is_a?(String)
|
51
|
+
raise ArgumentError, "Invalid label removal format. Expected [:variable, 'Label'], got #{item.inspect}"
|
52
|
+
end
|
53
|
+
|
54
|
+
# Remove label: n:Label
|
55
|
+
[:label, item[0], item[1]]
|
56
|
+
else
|
57
|
+
raise ArgumentError, "Invalid item type for REMOVE clause: #{item.class}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def render_item(item, query)
|
63
|
+
type, target, value = item
|
64
|
+
case type
|
65
|
+
when :property
|
66
|
+
# target is PropertyAccess
|
67
|
+
target.render(query) # Renders as "variable.property"
|
68
|
+
when :label
|
69
|
+
# target is variable symbol, value is label string
|
70
|
+
"#{target}:#{value}" # Labels are not parameterized
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|