rails_age 0.6.0 → 0.6.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6777870b945c8684ea2ef5d10b8e7411259e002001b074754840a9422b594cee
4
- data.tar.gz: '008ab62bbe28d408d390acd5e00cecc764507e21ae3d38ca94d219e9d299435f'
3
+ metadata.gz: 053d21a51d2e91cb61ea1678d2d84c05ac4a1a84a643334c4993df9f6ca9c3c0
4
+ data.tar.gz: 57f945d6055d9dca60414321e3f39dd09abfdb1a8f2a3a5fec0b4c5e3e9ee656
5
5
  SHA512:
6
- metadata.gz: 8a9c122ce1a8b5b173fc9ffd6ba45a4bf97965eb0681ec0d44e9779dcc1ef647da8e7e5110a5564a9f28c329ebe9e83ba832d920f4504bb5026650f41cc102d2
7
- data.tar.gz: f9217b16e0177746a438ab5fed8a4e5c7f4bb6ff02b3e31a2a4d1db7a8bd246550e33be6494e0533c70dcbcde3a54078ed041b2c3a08718cb9b020c73353eee5
6
+ metadata.gz: 017c585f995749dad85f341b41a01d2f94b0cc03fac6240d63e4755e8ab0c7148969793fbe9c308f13c0ea94d35eee3fb231516eb775817a53bef003843d41e4
7
+ data.tar.gz: 83760cc109544589665b7297776538f48f4ac0c14273455957e714ff6d9614e1a4e8d8ba862e39b8735a5f1e5a1f9c8ca5e429f1823aee8b98e38bad3dded2de
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Change Log
2
2
 
3
- ## VERSION 0.6.4 - 2024-xx-xx
3
+ ## VERSION 0.9.1 - xxxx-xx-xx
4
+
5
+ - **Edge Generator**
6
+ * add start-/end-nodes types to edge generator (would make scaffold easier), ie:
7
+ `rails generate apache_age:edge HasPet owner_role start_node:person end_node:pet`
8
+ with property and specified start-/end-nodes (person and pet nodes must have already been created)
9
+
10
+ - **Edge Scaffold** with node types?
11
+ * add `rails generate apache_age:edge_scaffold HasJob employee_role start_node:person end_node:company`
12
+
13
+ ## VERSION 0.9.0 - 2024-xx-xx
14
+ - **AGE visual paths graph**
15
+ * add `rails generate apache_age:visualize`
16
+
17
+ ## VERSION 0.8.0 - 2024-xx-xx
4
18
 
5
19
  - **cypher queries** (like active record queries)
6
20
  * schema override
@@ -8,11 +22,12 @@
8
22
  * paths support
9
23
  * select attributes support
10
24
 
11
- ## VERSION 0.6.3 - 2024-xx-xx
25
+ ## VERSION 0.7.0 - 2024-xx-xx
12
26
 
13
- - **Age Path**
27
+ - **Age Path** - nodes and edges combined
28
+ * add `rails generate apache_age:path_scaffold HasJob employee_role start_node:person end_node:company`
14
29
 
15
- ## VERSION 0.6.2 - 2024-xx-xx
30
+ ## VERSION 0.6.3 - 2024-xx-xx
16
31
 
17
32
  breaking change?: namespaces (by default) will use their own schema? (add to database.yml & schema.rb ?)
18
33
 
@@ -20,25 +35,26 @@ breaking change?: namespaces (by default) will use their own schema? (add to dat
20
35
 
21
36
  - **multiple AGE Schema**
22
37
 
23
- ## VERSION 0.6.1 - 2024-xx-xx
38
+ ## VERSION 0.6.2 - 2024-xx-xx
24
39
 
25
- - **Fix**
26
- * show validation errors in scaffold views
40
+ - **Query Sanitize**
27
41
 
28
- - **Edge Generator**
29
- * add start-/end-nodes types to edge generator (would make scaffold easier), ie:
30
- `rails generate apache_age:edge HasPet owner_role start_node:person end_node:pet`
31
- with property and specified start-/end-nodes (person and pet nodes must have already been created)
42
+ ## VERSION 0.6.1 - 2024-09-29
32
43
 
33
- - **Edge Scaffold** (generates edge, type, view and controller)
34
- * add `rails generate apache_age:edge_scaffold HasJob employee_role start_node:person end_node:company`
44
+ **Queries are not yet sanitize (injection filtered)!**
45
+
46
+ - **where nodes** - Edge and Node
47
+ - **where edges** - allow subquery on node attributes?
48
+ - **limit** - limit the number of results returned
49
+
50
+ ## VERSION 0.6.0 - 2024-06-28
35
51
 
36
- ## VERSION 0.6.0 - 2024-06-xx
52
+ **Document showing errors**
37
53
 
38
54
  **breaking changes**: update naming
39
55
  * renamed `Entities::Vertex` module to `Entities::Node`
40
56
  * renamed `UniqueVertex` to `UniqueNode`
41
- * rebamed `AgeTypeGenerator` to `Type::Factory`
57
+ * rebamed `AgeTypeGenerator.create_type_for` to `Type::Factory.type_for`
42
58
  * move `lib/generators/*` intp `lib/apache_age/generators`
43
59
 
44
60
  here is the [commit](https://github.com/marpori/rails_age_demo_app/commit/a6f0708f2bbc165eddbafe63896068a72d803b17) to see the changes te demo app to make it work for release 0.6.0
data/README.md CHANGED
@@ -147,24 +147,260 @@ rails generate apache_age:scaffold_node Person first_name last_name
147
147
  rails generate apache_age:scaffold_node Animals/Pet pet_name birthdate:date
148
148
  ```
149
149
 
150
- ### EDGE Scaffold Generation**
151
-
152
- NOTE: the generator will only allow `:node` (default type) for start_node and end_node, however, it is strongly recommended to specify the start_node and end_node types manually. _Hopefully, I can find a way to get the generators to recognize and allow the usage of custom node types. Thus eventually, I hope: `rails generate apache_age:node HasPet start_node:person end_node:pet caretaker_role` will work._
153
-
150
+ now you can test your nodes at:
154
151
  ```bash
155
- rails generate apache_age:edge HasJob employee_role begin_date:date
152
+ http://localhost/people
153
+ # and
154
+ http://localhost/animals_pets
156
155
  ```
157
156
 
158
- _edge scaffold is coming soon._
157
+ Note: Turbo seems to interfere with the default rails template's ability to show errors, this can easily be fixed by disabling turbo for forms that where turbo isn't needed by adding `data: { turbo: false }` to the form, ie:
158
+ ```ruby
159
+ <%= form_with(model: animals_pet, data: { turbo: false }) do |form| %>
160
+ ...
161
+ <% end %>
162
+ ```
163
+
164
+ ### EDGE Scaffold Generation**
159
165
 
160
166
  ```bash
161
167
  # without a namespace
162
168
  rails generate apache_age:scaffold_edge HasPet caretaker_role
169
+ rails generate apache_age:edge HasJob employee_role begin_date:date
163
170
 
164
171
  # with a namespace
165
- rails generate apache_age:scaffold_edge People/HasSpouse spousal_role
172
+ rails generate apache_age:scaffold_edge People/HasSpouce spousal_role
166
173
  ```
167
174
 
175
+ now you can test your edges at:
176
+ ```bash
177
+ http://localhost/has_pets
178
+ http://localhost/has_jobs
179
+ # and
180
+ http://localhost/people/has_spouses
181
+ ```
182
+
183
+ you can improve the view to only show the items you expect to be associated with the start- and end-node by changing the selects in the form from the generic form (finds all nodes):
184
+ ```ruby
185
+ <div>
186
+ <%= form.label :end_node, style: "display: block" %>
187
+ <%= form.collection_select(:end_id, ApacheAge::Node.all, :id, :display, prompt: 'Select an End-Node') %>
188
+ </div>
189
+ ```
190
+ to selecting a specific node expected (along with the desired 'name' in the list)
191
+ ```ruby
192
+ <div>
193
+ <%= form.label :end_node, style: "display: block" %>
194
+ <%= form.collection_select(:end_id, Company.all, :id, :company_name, prompt: 'Select a Company') %>
195
+ </div>
196
+ ```
197
+ so full form change for has_job could look like:
198
+ ```ruby
199
+ # app/views/has_jobs/_form.html.erb
200
+ <%= form_with(model: has_job, url: form_url) do |form| %>
201
+ <% if has_job.errors.any? %>
202
+ <div style="color: red">
203
+ <h2><%= pluralize(has_job.errors.count, "error") %> prohibited this has_job from being saved:</h2>
204
+
205
+ <ul>
206
+ <% has_job.errors.each do |error| %>
207
+ <li><%= error.full_message %></li>
208
+ <% end %>
209
+ </ul>
210
+ </div>
211
+ <% end %>
212
+
213
+ <div>
214
+ <%= form.label :employee_role, style: "display: block" %>
215
+ <%= form.text_field :employee_role %>
216
+ </div>
217
+
218
+ <div>
219
+ <%= form.label :start_node, style: "display: block" %>
220
+ <%= form.collection_select(:start_id, Person.all, :id, :first_name, prompt: 'Select a person') %>
221
+ </div>
222
+
223
+ <div>
224
+ <%= form.label :end_node, style: "display: block" %>
225
+ <%= form.collection_select(:end_id, Company.all, :id, :company_name, prompt: 'Select a Company') %>
226
+ </div>
227
+
228
+ <div>
229
+ <%= form.submit %>
230
+ </div>
231
+ <% end %>
232
+ ```
233
+
234
+ To make your code more robust (enforce that the appropriate node type is associate with the start- and end-nodes) you can adjust the edge definition by adding the node type to the `start_node` and `end_node` attributes.
235
+ from
236
+ ```ruby
237
+ attribute :start_node
238
+ attribute :end_node
239
+ ```
240
+ to:
241
+ ```ruby
242
+ attribute :start_node, :person
243
+ attribute :end_node, :company
244
+ ```
245
+ For example you can make the edge/has_pet.rb more robust by making the model look like:
246
+ ```ruby
247
+ # app/edges/has_job.rb
248
+ class HasJob
249
+ include ApacheAge::Entities::Edge
250
+
251
+ attribute :employee_role, :string
252
+ attribute :start_node, :person
253
+ attribute :end_node, :company
254
+
255
+ validates :employee_role, presence: true
256
+ validate :validate_unique
257
+
258
+ private
259
+
260
+ def validate_unique
261
+ ApacheAge::Validators::UniqueEdge
262
+ .new(attributes: %i[employee_role start_node end_node])
263
+ .validate(self)
264
+ end
265
+ end
266
+ ```
267
+
268
+ The generator will only allow `:node` (default type) since at the time of running the generator (at least within tests, the custom types are not known), eventually, I hope to find a way to fix that and allow:
269
+ `rails generate apache_age:node HasPet start_node:person end_node:pet caretaker_role`
270
+ but that doesn't work yet!
271
+
272
+ ### AGE Rails Quick Example
273
+
274
+ ```bash
275
+ rails new stone_age --database=postresql
276
+ cd stone_age
277
+
278
+ bundle add rails_age
279
+ bundle install
280
+ bin/rails apache_age:install
281
+ bin/rails apache_age:override_db_migrate
282
+ rails db:create
283
+ rails db:migrate
284
+ rails generate apache_age:scaffold_node Person first_name, last_name, gender
285
+ rails generate apache_age:scaffold_node Pet name gender species
286
+ rails generate apache_age:scaffold_edge HasChild role:string
287
+ rails generate apache_age:scaffold_edge HasSibling role:string
288
+ rails generate apache_age:scaffold_edge HasSpouse role:string
289
+
290
+ # seed file: [db/seed.rb](SEED.md)
291
+ rails db:seed
292
+
293
+ # Console Usage (seed doesn't provide any pets)
294
+ dino = Pet.create(name: 'Dino', gender: 'male', species: 'dinosaur')
295
+ dino.to_h
296
+
297
+ # find a person
298
+ fred = Person.find_by(first_name: 'Fred', last_name: 'Flintstone')
299
+ fred.to_h
300
+
301
+ pebbles = Person.find_by(first_name: 'Pebbles')
302
+ pebbles.to_h
303
+
304
+ # find an edge
305
+ father_relationship = HasChild.find_by(start_node: fred, end_node: pebbles)
306
+ father_relationship.to_h
307
+ > {:id=>1407374883553310,
308
+ :end_id=>844424930131996,
309
+ :start_id=>844424930131986,
310
+ :role=>"father",
311
+ :end_node=>{:id=>844424930131996, :last_name=>"Flintstone", :first_name=>"Pebbles", :gender=>"female"},
312
+ :start_node=>{:id=>844424930131986, :last_name=>"Flintstone", :first_name=>"Fred", :gender=>"male"}}
313
+
314
+ # where - find multiple nodes
315
+ family = Person.where(last_name: 'Flintstone').order(:first_name).limit(4).all.puts family.map(&:to_h)
316
+
317
+ family
318
+ > [{:id=>844424930131974, :last_name=>"Flintstone", :first_name=>"Ed", :gender=>"male"},
319
+ > {:id=>844424930131976, :last_name=>"Flintstone", :first_name=>"Edna", :gender=>"female"},
320
+ ? {:id=>844424930131986, :last_name=>"Flintstone", :first_name=>"Fred", :gender=>"male"},
321
+ > {:id=>844424930131975, :last_name=>"Flintstone", :first_name=>"Giggles", :gender=>"male"}]
322
+
323
+ # all - unsorted
324
+ all_family = Person.where(last_name: 'Flintstone').all
325
+ puts all_family.map(&:to_h)
326
+ > {:id=>844424930131969, :last_name=>"Flintstone", :first_name=>"Zeke", :gender=>"female"}
327
+ > {:id=>844424930131970, :last_name=>"Flintstone", :first_name=>"Jed", :gender=>"male"}
328
+ > {:id=>844424930131971, :last_name=>"Flintstone", :first_name=>"Rockbottom", :gender=>"male"}
329
+ > {:id=>844424930131974, :last_name=>"Flintstone", :first_name=>"Ed", :gender=>"male"}
330
+ > {:id=>844424930131975, :last_name=>"Flintstone", :first_name=>"Giggles", :gender=>"male"}
331
+ > {:id=>844424930131976, :last_name=>"Flintstone", :first_name=>"Edna", :gender=>"female"}
332
+ > {:id=>844424930131986, :last_name=>"Flintstone", :first_name=>"Fred", :gender=>"male"}
333
+ > {:id=>844424930131987, :last_name=>"Flintstone", :first_name=>"Wilma", :gender=>"female"}
334
+ > {:id=>844424930131995, :last_name=>"Flintstone", :first_name=>"Stoney", :gender=>"male"}
335
+ > {:id=>844424930131996, :last_name=>"Flintstone", :first_name=>"Pebbles", :gender=>"female"}
336
+
337
+ # where - multiple edges (relations) - for now only edge attributes and start/end nodes can be queried
338
+ parental_relations = HasChild.where(end_node: pebbles)
339
+ puts parental_relations.map(&:to_h)
340
+ > {:id=>1407374883553310, :end_id=>844424930131996, :start_id=>844424930131986, :role=>"father", :end_node=>{:id=>844424930131996, :last_name=>"Flintstone", :first_name=>"Pebbles", :gender=>"female"}, :start_node=>{:id=>844424930131986, :last_name=>"Flintstone", :first_name=>"Fred", :gender=>"male"}}
341
+ > {:id=>1407374883553309, :end_id=>844424930131996, :start_id=>844424930131987, :role=>"mother", :end_node=>{:id=>844424930131996, :last_name=>"Flintstone", :first_name=>"Pebbles", :gender=>"female"}, :start_node=>{:id=>844424930131987, :last_name=>"Flintstone", :first_name=>"Wilma", :gender=>"female"}}
342
+
343
+
344
+ raw_pg_results = Person.where(last_name: 'Flintstone').order(:first_name).limit(4).execute
345
+ => #<PG::Result:0x000000012255f348 status=PGRES_TUPLES_OK ntuples=4 nfields=1 cmd_tuples=4>
346
+ raw_pg_results.values
347
+ > [["{\"id\": 844424930131974, \"label\": \"Person\", \"properties\": {\"gender\": \"male\", \"last_name\": \"Flintstone\", \"first_name\": \"Ed\"}}::vertex"],
348
+ > ["{\"id\": 844424930131976, \"label\": \"Person\", \"properties\": {\"gender\": \"female\", \"last_name\": \"Flintstone\", \"first_name\": \"Edna\"}}::vertex"],
349
+ > ["{\"id\": 844424930131986, \"label\": \"Person\", \"properties\": {\"gender\": \"male\", \"last_name\": \"Flintstone\", \"first_name\": \"Fred\"}}::vertex"],
350
+ > ["{\"id\": 844424930131975, \"label\": \"Person\", \"properties\": {\"gender\": \"male\", \"last_name\": \"Flintstone\", \"first_name\": \"Giggles\"}}::vertex"]]
351
+ ```
352
+
353
+ ### Age Cypher Queries
354
+
355
+ ```ruby
356
+ flintstone_family =
357
+ Person.where(last_name: 'Flintstone')
358
+ .order(:first_name)
359
+ .limit(4).all
360
+ .map(&:to_h)
361
+
362
+ # generates the query
363
+ SELECT *
364
+ FROM cypher('age_schema', $$
365
+ MATCH (find:Person)
366
+ WHERE find.last_name = 'Flintstone'
367
+ RETURN find
368
+ ORDER BY find.first_name
369
+ LIMIT 4
370
+ $$) as (Person agtype);
371
+
372
+ # and returns:
373
+ [{:id=>844424930131974, :last_name=>"Flintstone", :first_name=>"Ed", :gender=>"male"},
374
+ {:id=>844424930131976, :last_name=>"Flintstone", :first_name=>"Edna", :gender=>"female"},
375
+ {:id=>844424930131986, :last_name=>"Flintstone", :first_name=>"Fred", :gender=>"male"},
376
+ {:id=>844424930131975, :last_name=>"Flintstone", :first_name=>"Giggles", :gender=>"male"}]
377
+ ```
378
+
379
+ ```ruby
380
+ query =
381
+ Person.
382
+ cypher('age_schema')
383
+ .match("(a:Person), (b:Person)")
384
+ .where("a.name = 'Node A'", "b.name = 'Node B'")
385
+ .return("a.name", "b.name")
386
+ .as("name_a agtype, name_b agtype")
387
+ .execute
388
+ ```
389
+
390
+ or more generally:
391
+
392
+ ```ruby
393
+ tihen =
394
+ ApacheAge::Cypher
395
+ .new('age_schema')
396
+ .create("(person:Person {name: 'Tihen'})")
397
+ .return('person')
398
+ .as('Person agtype')
399
+ .execute
400
+ ```
401
+
402
+ see [AGE Cypher Queries](AGE_CYPHER_QUERIES.md)
403
+
168
404
  ### AGE Usage within Rails Console
169
405
 
170
406
  see [AGE Usage within Rails Console](AGE_CONSOLE_USAGE.md)
@@ -0,0 +1,95 @@
1
+ module ApacheAge
2
+ class Cypher
3
+ class << self
4
+ attr_accessor :model_class
5
+ end
6
+
7
+ def initialize(graph_name = 'age_schema')
8
+ @graph_name = graph_name
9
+ @query = ""
10
+ @as_type = "result agtype"
11
+ end
12
+
13
+ def match(pattern)
14
+ @query += "MATCH #{pattern} "
15
+ self
16
+ end
17
+
18
+ # WITH n.name as name, n.age as age
19
+ # WITH otherPerson, count(*) AS foaf WHERE foaf > 1
20
+ # with has a lot of cases - see docs
21
+ def with(*conditions)
22
+ @query += "WITH #{variables.join(', ')} "
23
+ self
24
+ end
25
+
26
+ def where(*conditions)
27
+ condition_str = conditions.join(' AND ')
28
+ # @query += "WHERE #{condition_str} "
29
+ # If there's already a WHERE clause in the query, append to it (they need to be adjacent!)
30
+ @query += (@query.include?("WHERE") ? " AND #{condition_str} " : "WHERE #{condition_str} ")
31
+ self
32
+ end
33
+
34
+ # ORDER BY n.age DESC, n.name ASC
35
+ def order_by(*conditions)
36
+ @query += "ORDER BY #{variables.join(', ')} "
37
+ self
38
+ end
39
+
40
+ # can use full names n.name or aliases (with) name
41
+ def return(*variables)
42
+ @query += "RETURN #{variables.join(', ')} "
43
+ self
44
+ end
45
+
46
+ def create(node)
47
+ @query += "CREATE #{node} "
48
+ self
49
+ end
50
+
51
+ def set(properties)
52
+ @query += "SET #{properties} "
53
+ self
54
+ end
55
+
56
+ def remove(property)
57
+ @query += "REMOVE #{property} "
58
+ self
59
+ end
60
+
61
+ def delete(entity)
62
+ @query += "DELETE #{entity} "
63
+ self
64
+ end
65
+
66
+ def merge(pattern)
67
+ @query += "MERGE #{pattern} "
68
+ self
69
+ end
70
+
71
+ def skip(count)
72
+ @query += "SKIP #{count} "
73
+ self
74
+ end
75
+
76
+ def limit(count)
77
+ @query += "LIMIT #{count} "
78
+ self
79
+ end
80
+
81
+ def as(type)
82
+ @as_type = type
83
+ self
84
+ end
85
+
86
+ def to_cypher
87
+ "SELECT * FROM cypher('#{@graph_name}', $$ #{@query.strip} $$) AS (#{@as_type});"
88
+ end
89
+
90
+ def execute
91
+ cypher_sql = to_cypher
92
+ ActiveRecord::Base.connection.execute(cypher_sql)
93
+ end
94
+ end
95
+ end
@@ -8,40 +8,24 @@ module ApacheAge
8
8
  instance
9
9
  end
10
10
 
11
- def find_by(attributes)
12
- return nil if attributes.reject{ |k,v| v.blank? }.empty?
13
-
14
- edge_keys = [:start_id, :start_node, :end_id, :end_node]
15
- return find_edge(attributes) if edge_keys.any? { |key| attributes.include?(key) }
16
-
17
- where_clause = attributes.map { |k, v| "find.#{k} = '#{v}'" }.join(' AND ')
18
- cypher_sql = find_sql(where_clause)
19
-
20
- execute_find(cypher_sql)
11
+ def where(attributes)
12
+ query_builder = QueryBuilder.new(self)
13
+ query_builder.where(attributes)
21
14
  end
22
15
 
23
- def find(id)
24
- where_clause = "id(find) = #{id}"
25
- cypher_sql = find_sql(where_clause)
26
- execute_find(cypher_sql)
27
- end
28
-
29
- def all
30
- age_results = ActiveRecord::Base.connection.execute(all_sql)
31
- return [] if age_results.values.count.zero?
16
+ def all = QueryBuilder.new(self).all
17
+ def first = QueryBuilder.new(self).limit(1).first
18
+ def find(id) = where(id: id).first
32
19
 
33
- age_results.values.map do |result|
34
- json_string = result.first.split('::').first
35
- hash = JSON.parse(json_string)
36
- attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
20
+ def find_by(attributes)
21
+ return nil if attributes.reject { |k, v| v.blank? }.empty?
37
22
 
38
- new(**attribs)
39
- end
23
+ where(attributes).limit(1).first
40
24
  end
41
25
 
42
26
  # Private stuff
43
27
 
44
- def find_edge(attributes)
28
+ def where_edge(attributes)
45
29
  where_attribs =
46
30
  attributes
47
31
  .compact
@@ -57,9 +41,9 @@ module ApacheAge
57
41
  where_clause = [where_attribs, where_start_id, where_end_id].compact.join(' AND ')
58
42
  return nil if where_clause.empty?
59
43
 
60
- cypher_sql = find_edge_sql(where_clause)
44
+ cypher_sql = edge_sql(where_clause)
61
45
 
62
- execute_find(cypher_sql)
46
+ execute_where(cypher_sql)
63
47
  end
64
48
 
65
49
  def age_graph = 'age_schema'
@@ -70,17 +54,21 @@ module ApacheAge
70
54
  age_type == 'vertex' ? "(find:#{age_label})" : "(start_node)-[find:#{age_label}]->(end_node)"
71
55
  end
72
56
 
73
- def execute_find(cypher_sql)
74
- age_result = ActiveRecord::Base.connection.execute(cypher_sql)
75
- return nil if age_result.values.count.zero?
57
+ def execute_sql(cypher_sql) = ActiveRecord::Base.connection.execute(cypher_sql)
76
58
 
77
- age_type = age_result.values.first.first.split('::').last
78
- json_data = age_result.values.first.first.split('::').first
59
+ def execute_find(cypher_sql) = execute_where(cypher_sql).first
79
60
 
80
- hash = JSON.parse(json_data)
81
- attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
61
+ def execute_where(cypher_sql)
62
+ age_results = ActiveRecord::Base.connection.execute(cypher_sql)
63
+ return [] if age_results.values.count.zero?
82
64
 
83
- new(**attribs)
65
+ age_results.values.map do |value|
66
+ json_data = value.first.split('::').first
67
+ hash = JSON.parse(json_data)
68
+ attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
69
+
70
+ new(**attribs)
71
+ end
84
72
  end
85
73
 
86
74
  def all_sql
@@ -93,7 +81,7 @@ module ApacheAge
93
81
  SQL
94
82
  end
95
83
 
96
- def find_sql(where_clause)
84
+ def node_sql(where_clause)
97
85
  <<-SQL
98
86
  SELECT *
99
87
  FROM cypher('#{age_graph}', $$
@@ -104,7 +92,7 @@ module ApacheAge
104
92
  SQL
105
93
  end
106
94
 
107
- def find_edge_sql(where_clause)
95
+ def edge_sql(where_clause)
108
96
  <<-SQL
109
97
  SELECT *
110
98
  FROM cypher('#{age_graph}', $$
@@ -114,6 +102,48 @@ module ApacheAge
114
102
  $$) as (#{age_label} agtype);
115
103
  SQL
116
104
  end
105
+
106
+ private
107
+
108
+ def where_node_clause(attributes)
109
+ build_core_where_clause(attributes)
110
+ end
111
+
112
+ def where_edge_clause(attributes)
113
+ core_attributes = attributes.except(:end_id, :start_id, :end_node, :start_node)
114
+ core_clauses = core_attributes.empty? ? nil : build_core_where_clause(core_attributes)
115
+
116
+ end_id =
117
+ if attributes[:end_id]
118
+ end_id = attributes[:end_id]
119
+ elsif attributes[:end_node].is_a?(Node)
120
+ end_id = attributes[:end_node]&.id
121
+ end
122
+ where_end_id = end_id ? "id(end_node) = #{end_id}" : nil
123
+
124
+ start_id =
125
+ if attributes[:start_id]
126
+ start_id = attributes[:start_id]
127
+ elsif attributes[:start_node].is_a?(Node)
128
+ start_id = attributes[:start_node]&.id
129
+ end
130
+ where_start_id = start_id ? "id(start_node) = #{start_id}" : nil
131
+
132
+ where_end_attrs =
133
+ attributes[:end_node].map { |k, v| "end_node.#{k} = '#{v}'" } if attributes[:end_node].is_a?(Hash)
134
+ where_start_attrs =
135
+ attributes[:start_node].map { |k, v| "start_node.#{k} = '#{v}'" } if attributes[:start_node].is_a?(Hash)
136
+
137
+ [core_clauses, where_start_id, where_end_id, where_start_attrs, where_end_attrs]
138
+ .flatten.compact.join(' AND ')
139
+ end
140
+
141
+ def build_core_where_clause(attributes)
142
+ attributes
143
+ .compact
144
+ .map { |k, v| k == :id ? "id(find) = #{v}" : "find.#{k} = '#{v}'"}
145
+ .join(' AND ')
146
+ end
117
147
  end
118
148
  end
119
149
  end
@@ -7,6 +7,8 @@ module ApacheAge
7
7
  include ActiveModel::Model
8
8
  include ActiveModel::Dirty
9
9
  include ActiveModel::Attributes
10
+ include ActiveModel::Validations
11
+ include ActiveModel::Validations::Callbacks
10
12
 
11
13
  attribute :id, :integer
12
14
  # attribute :label, :string
@@ -7,6 +7,8 @@ module ApacheAge
7
7
  include ActiveModel::Model
8
8
  include ActiveModel::Dirty
9
9
  include ActiveModel::Attributes
10
+ include ActiveModel::Validations
11
+ include ActiveModel::Validations::Callbacks
10
12
 
11
13
  attribute :id, :integer
12
14
 
@@ -0,0 +1,27 @@
1
+ module ApacheAge
2
+ module Entities
3
+ module Path
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ include ActiveModel::Model
8
+ include ActiveModel::Dirty
9
+ include ActiveModel::Attributes
10
+
11
+ attribute :id, :integer
12
+ # attribute :label, :string
13
+ attribute :end_id, :integer
14
+ attribute :start_id, :integer
15
+ # override with a specific node type in the defining class
16
+ attribute :end_node
17
+ attribute :start_node
18
+
19
+ validates :end_node, :start_node, presence: true
20
+ validate :validate_nodes
21
+
22
+ extend ApacheAge::Entities::ClassMethods
23
+ include ApacheAge::Entities::CommonMethods
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,159 @@
1
+ # query =
2
+ # Person.
3
+ # cypher('age_schema')
4
+ # .match("(a:Person), (b:Person)")
5
+ # .where("a.name = 'Node A'", "b.name = 'Node B'")
6
+ # .return("a.name", "b.name")
7
+ # .as("name_a agtype, name_b agtype")
8
+ # .execute
9
+ # def cypher(graph_name = 'age_schema')
10
+ # ApacheAge::Cypher.new(graph_name)
11
+ # self
12
+ # end
13
+
14
+ module ApacheAge
15
+ module Entities
16
+ class QueryBuilder
17
+ attr_accessor :where_clauses, :order_clause, :limit_clause, :model_class, :match_clause,
18
+ :graph_name, :return_clause, :return_names, :return_variables
19
+
20
+ def initialize(model_class, graph_name: nil)
21
+ @model_class = model_class
22
+ @where_clauses = []
23
+ @return_names = ['find']
24
+ @return_clause = 'find'
25
+ @return_variables = []
26
+ @order_clause = nil
27
+ @limit_clause = nil
28
+ @match_clause = model_class.match_clause
29
+ @graph_name = graph_name || model_class.age_graph
30
+ end
31
+
32
+ # def cypher(graph_name = 'age_schema')
33
+ # return self if graph_name.blank?
34
+
35
+ # @graph_name = graph_name
36
+ # self
37
+ # end
38
+
39
+ def match(match_string)
40
+ @match_clause = match_string
41
+ self
42
+ end
43
+
44
+ # need to handle string inputs too: ie: "id(find) = #{id}"
45
+ def where(attributes)
46
+ return self if attributes.blank?
47
+
48
+ @where_clauses <<
49
+ if attributes.is_a?(String)
50
+ if attributes.include?('id(') || attributes.include?('find.')
51
+ attributes
52
+ else
53
+ "find.#{attributes}"
54
+ end
55
+ else
56
+ edge_keys = [:start_id, :start_node, :end_id, :end_node]
57
+ if edge_keys.any? { |key| attributes.include?(key) }
58
+ model_class.send(:where_edge_clause, attributes)
59
+ else
60
+ model_class.send(:where_node_clause, attributes)
61
+ end
62
+ end
63
+
64
+ self
65
+ end
66
+
67
+ # New return method
68
+ def return(*variables)
69
+ return self if variables.blank?
70
+
71
+ @return_variables = variables
72
+ # @return_names = variables.empty? ? ['find'] : variables
73
+ # @return_clause = variables.empty? ? 'find' : "find.#{variables.join(', find.')}"
74
+ self
75
+ end
76
+
77
+ def order(ordering)
78
+ @order_clause = nil
79
+ return self if ordering.blank?
80
+
81
+ order_by_values = Array.wrap(ordering).map { |order| parse_ordering(order) }.join(', ')
82
+ @order_clause = "ORDER BY #{order_by_values}"
83
+ self
84
+ end
85
+
86
+ def limit(limit_value)
87
+ @limit_clause = "LIMIT #{limit_value}"
88
+ self
89
+ end
90
+
91
+ def all
92
+ cypher_sql = build_query
93
+ results = model_class.send(:execute_where, cypher_sql)
94
+ return results if return_variables.empty?
95
+
96
+ results.map(&:to_h).map { _1.slice(*return_variables) }
97
+ end
98
+
99
+ def execute
100
+ cypher_sql = build_query
101
+ model_class.send(:execute_sql, cypher_sql)
102
+ end
103
+
104
+ def first
105
+ cypher_sql = build_query(limit_clause || "LIMIT 1")
106
+ model_class.send(:execute_find, cypher_sql)
107
+ end
108
+
109
+ def to_sql
110
+ build_query.strip
111
+ end
112
+
113
+ private
114
+
115
+ def parse_ordering(ordering)
116
+ if ordering.is_a?(Hash)
117
+ # Convert hash into "find.key direction" format and join with commas
118
+ ordering = ordering.map { |k, v| "find.#{k} #{v}" }.join(', ')
119
+ elsif ordering.is_a?(Symbol)
120
+ # If it's a symbol, simply prepend "find."
121
+ ordering = "find.#{ordering}"
122
+ elsif ordering.is_a?(String)
123
+ # If it's a string, assume it's already in the correct format
124
+ ordering = ordering
125
+ elsif ordering.is_a?(Array)
126
+ # If it's an array, process each element recursively
127
+ ordering = ordering.map do |order|
128
+ if order.is_a?(Hash)
129
+ order.map { |k, v| "find.#{k} #{v}" }.join(', ')
130
+ elsif order.is_a?(Symbol)
131
+ "find.#{order}"
132
+ elsif order.is_a?(String)
133
+ order
134
+ else
135
+ raise ArgumentError, 'Array elements must be a string, symbol, or hash'
136
+ end
137
+ end.join(', ')
138
+ else
139
+ raise ArgumentError, 'Ordering must be a string, symbol, hash, or array'
140
+ end
141
+ end
142
+
143
+ def build_query(_extra_clause = nil)
144
+ where_sql = where_clauses.any? ? "WHERE #{where_clauses.join(' AND ')}" : ''
145
+ order_by = order_clause.present? ? order_clause : ''
146
+ <<-SQL.squish
147
+ SELECT *
148
+ FROM cypher('#{graph_name}', $$
149
+ MATCH #{match_clause}
150
+ #{where_sql}
151
+ RETURN #{return_clause}
152
+ #{order_clause}
153
+ #{limit_clause}
154
+ $$) AS (#{return_names.join(' agtype, ')} agtype);
155
+ SQL
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,4 @@
1
+ module ApacheAge
2
+ class Path
3
+ end
4
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsAge
2
- VERSION = '0.6.0'
2
+ VERSION = '0.6.1'
3
3
  end
data/lib/rails_age.rb CHANGED
@@ -6,13 +6,17 @@ module RailsAge
6
6
  end
7
7
 
8
8
  module ApacheAge
9
+ require 'apache_age/cypher.rb'
10
+ require 'apache_age/entities/query_builder'
9
11
  require 'apache_age/entities/class_methods'
10
12
  require 'apache_age/entities/common_methods'
11
13
  require 'apache_age/entities/entity'
12
14
  require 'apache_age/entities/node'
13
15
  require 'apache_age/entities/edge'
16
+ # require 'apache_age/entities/path'
14
17
  require 'apache_age/node'
15
18
  require 'apache_age/edge'
19
+ # require 'apache_age/path'
16
20
  require 'apache_age/validators/expected_node_type'
17
21
  require 'apache_age/validators/unique_node'
18
22
  require 'apache_age/validators/unique_edge'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_age
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bill Tihen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-27 00:00:00.000000000 Z
11
+ date: 2024-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -30,6 +30,20 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: ostruct
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
33
47
  - !ruby/object:Gem::Dependency
34
48
  name: rspec-rails
35
49
  requirement: !ruby/object:Gem::Requirement
@@ -96,14 +110,18 @@ files:
96
110
  - config/routes.rb
97
111
  - db/migrate/20240521062349_add_apache_age.rb
98
112
  - db/schema.rb
113
+ - lib/apache_age/cypher.rb
99
114
  - lib/apache_age/edge.rb
100
115
  - lib/apache_age/entities/class_methods.rb
101
116
  - lib/apache_age/entities/common_methods.rb
102
117
  - lib/apache_age/entities/edge.rb
103
118
  - lib/apache_age/entities/entity.rb
104
119
  - lib/apache_age/entities/node.rb
120
+ - lib/apache_age/entities/path.rb
121
+ - lib/apache_age/entities/query_builder.rb
105
122
  - lib/apache_age/entities/vertex.rb
106
123
  - lib/apache_age/node.rb
124
+ - lib/apache_age/path.rb
107
125
  - lib/apache_age/types/factory.rb
108
126
  - lib/apache_age/validators/expected_node_type.rb
109
127
  - lib/apache_age/validators/node_type_validator.rb
@@ -162,14 +180,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
162
180
  requirements:
163
181
  - - ">="
164
182
  - !ruby/object:Gem::Version
165
- version: '3.0'
183
+ version: '3.2'
166
184
  required_rubygems_version: !ruby/object:Gem::Requirement
167
185
  requirements:
168
186
  - - ">="
169
187
  - !ruby/object:Gem::Version
170
188
  version: '0'
171
189
  requirements: []
172
- rubygems_version: 3.5.10
190
+ rubygems_version: 3.5.18
173
191
  signing_key:
174
192
  specification_version: 4
175
193
  summary: Apache AGE plugin for Rails 7.x