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 +4 -4
- data/CHANGELOG.md +31 -15
- data/README.md +243 -7
- data/lib/apache_age/cypher.rb +95 -0
- data/lib/apache_age/entities/class_methods.rb +68 -38
- data/lib/apache_age/entities/edge.rb +2 -0
- data/lib/apache_age/entities/node.rb +2 -0
- data/lib/apache_age/entities/path.rb +27 -0
- data/lib/apache_age/entities/query_builder.rb +159 -0
- data/lib/apache_age/path.rb +4 -0
- data/lib/rails_age/version.rb +1 -1
- data/lib/rails_age.rb +4 -0
- metadata +22 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 053d21a51d2e91cb61ea1678d2d84c05ac4a1a84a643334c4993df9f6ca9c3c0
|
4
|
+
data.tar.gz: 57f945d6055d9dca60414321e3f39dd09abfdb1a8f2a3a5fec0b4c5e3e9ee656
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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.
|
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.
|
38
|
+
## VERSION 0.6.2 - 2024-xx-xx
|
24
39
|
|
25
|
-
- **
|
26
|
-
* show validation errors in scaffold views
|
40
|
+
- **Query Sanitize**
|
27
41
|
|
28
|
-
-
|
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
|
-
|
34
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
152
|
+
http://localhost/people
|
153
|
+
# and
|
154
|
+
http://localhost/animals_pets
|
156
155
|
```
|
157
156
|
|
158
|
-
|
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/
|
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
|
12
|
-
|
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
|
24
|
-
|
25
|
-
|
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
|
-
|
34
|
-
|
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
|
-
|
39
|
-
end
|
23
|
+
where(attributes).limit(1).first
|
40
24
|
end
|
41
25
|
|
42
26
|
# Private stuff
|
43
27
|
|
44
|
-
def
|
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 =
|
44
|
+
cypher_sql = edge_sql(where_clause)
|
61
45
|
|
62
|
-
|
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
|
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
|
-
|
78
|
-
json_data = age_result.values.first.first.split('::').first
|
59
|
+
def execute_find(cypher_sql) = execute_where(cypher_sql).first
|
79
60
|
|
80
|
-
|
81
|
-
|
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
|
-
|
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
|
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
|
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
|
@@ -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
|
data/lib/rails_age/version.rb
CHANGED
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.
|
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-
|
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.
|
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.
|
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
|