rails_age 0.6.0 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
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