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.
Files changed (96) 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 +61 -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 +16 -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/sig/activecypher.rbs +4 -0
  96. metadata +172 -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ VERSION = '0.3.0'
5
+ end
@@ -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