pglpg 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +40 -4
- data/lib/pglpg/context.rb +5 -0
- data/lib/pglpg/neighborhood.rb +91 -0
- data/lib/pglpg/reachability.rb +15 -9
- data/lib/pglpg/testing.rb +8 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d0ce8830ee927b0b6f98d9a8601ce9f86bb3bea4570de038f6274f9f1008884
|
|
4
|
+
data.tar.gz: d0ba5b1cf855c1e9324bbbae62dd204248c34344944a2848a721af581b58580e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b6b63b7e1b93393c333a2a250c6b6eb854f164a81768df72ca2ea421a46ca0860e35922e1fdc8484bd517a910de3c3b680e499e75b060a91aa949250fa1308f
|
|
7
|
+
data.tar.gz: 68b0ccc4e5d4237da44578d87fe010b322baf53ec0feeefbd83a6393d0744f1f1b395b17994b00259ebe851872686db57762234385ec2e52e62b4b92027967a6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-11-05)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `Context#neighborhood` returns typed visible nodes with minimum depths and
|
|
8
|
+
the eligible edges between them in one PostgreSQL statement and snapshot.
|
|
9
|
+
The seed counts toward the node budget. Independent node and edge overflow
|
|
10
|
+
probes give exact truncation flags, including at a zero edge budget. Mixed
|
|
11
|
+
directed and symmetric types use the neighborhood direction rules.
|
|
12
|
+
|
|
13
|
+
- The shared visibility suite in `Pglpg::Testing` now observes neighborhood
|
|
14
|
+
nodes, edges, order and both truncation flags when comparing results before
|
|
15
|
+
and after hidden data is added.
|
|
16
|
+
|
|
3
17
|
## 0.4.0 (2026-11-04)
|
|
4
18
|
|
|
5
19
|
### Added
|
data/README.md
CHANGED
|
@@ -8,22 +8,27 @@ ordinary tables, written inside your own transactions. Every query applies
|
|
|
8
8
|
your visibility policy to every node and edge on every hop, so hidden records
|
|
9
9
|
never act as bridges and never show up in counts or limits.
|
|
10
10
|
|
|
11
|
-
Status: **0.
|
|
12
|
-
|
|
11
|
+
Status: **0.5.0** release candidate, with bounded neighborhoods that return
|
|
12
|
+
nodes and edges from one snapshot with exact truncation flags. The design and its decisions live in the
|
|
13
13
|
[pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
|
|
14
14
|
ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
18
|
```ruby
|
|
19
|
-
gem "pglpg", "~> 0.
|
|
19
|
+
gem "pglpg", "~> 0.5.0"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Before 1.0, minor releases may break compatibility. Read the
|
|
23
23
|
[0.3.0 upgrade notes](CHANGELOG.md) and the migration steps below before
|
|
24
24
|
upgrading from 0.2. Applications still on 0.1 must also apply the 0.2 notes.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
## Upgrade from 0.4
|
|
27
|
+
|
|
28
|
+
Bounded neighborhoods are additive and require no schema migration. Existing
|
|
29
|
+
reachability and MATCH queries keep their behavior. Applications using the shared
|
|
30
|
+
visibility suite should rerun it: it now also observes neighborhood nodes, edges,
|
|
31
|
+
ordering and both truncation flags.
|
|
27
32
|
|
|
28
33
|
## Upgrade from 0.3
|
|
29
34
|
|
|
@@ -230,6 +235,37 @@ isolation level. `deadline` is in seconds and can only shorten the configured
|
|
|
230
235
|
`statement_timeout`; with no timeout configured it applies alone. Running out
|
|
231
236
|
of time raises `Pglpg::QueryTimeout` and leaves your transaction usable.
|
|
232
237
|
|
|
238
|
+
## Bounded neighborhoods (0.5)
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
r = g.neighborhood(from: ann, via: %i[KNOWS FRIEND], direction: :both,
|
|
242
|
+
max_depth: 2, max_nodes: 50, max_edges: 100, deadline: 2.0)
|
|
243
|
+
r.nodes.map { |node, depth| [node.ref, depth] }
|
|
244
|
+
r.edges.map { |edge| [edge.ref, edge.source, edge.target] }
|
|
245
|
+
r.nodes_truncated?
|
|
246
|
+
r.edges_truncated?
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The result contains typed `Pglpg::Node` values with their minimum depths and
|
|
250
|
+
typed `Pglpg::Edge` values, from one statement and snapshot. The seed appears
|
|
251
|
+
at depth zero and counts toward `max_nodes`. Nodes are ordered by depth then
|
|
252
|
+
ID, and edges by ID. Visibility and the context's date rules apply before
|
|
253
|
+
selection and overflow probes.
|
|
254
|
+
|
|
255
|
+
`via` filters both traversal and the edges returned between selected nodes.
|
|
256
|
+
Directed types follow `:out`, `:in`, or either direction with `:both`;
|
|
257
|
+
symmetric types follow both directions with any option. Mixed lists are
|
|
258
|
+
allowed. Traversal direction selects nodes. The result includes eligible
|
|
259
|
+
stored edges between those nodes regardless of direction, preserving their
|
|
260
|
+
stored source and target.
|
|
261
|
+
|
|
262
|
+
The two flags report exact overflow independently. `edges_truncated?` is
|
|
263
|
+
true only when another eligible edge exists between returned nodes; edges
|
|
264
|
+
touching an omitted node cannot affect it. An exactly full cap gives false.
|
|
265
|
+
`max_nodes` is 1 through `max_rows`, `max_edges` is 0 through `max_rows`,
|
|
266
|
+
and `max_depth` is at least 1. A zero edge cap still probes for an eligible
|
|
267
|
+
edge. See the [API contract](https://knowen.md/nodes/36329) for the full rules.
|
|
268
|
+
|
|
233
269
|
## Models and eager loading
|
|
234
270
|
|
|
235
271
|
`require "pglpg/model"` adds optional ActiveModel records bound to a graph and
|
data/lib/pglpg/context.rb
CHANGED
|
@@ -76,6 +76,11 @@ module Pglpg
|
|
|
76
76
|
Reachable.new(self, from:, via:, direction:, max_depth:, max_nodes:, deadline:).call
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# A bounded visible induced subgraph, including the seed, in one snapshot.
|
|
80
|
+
def neighborhood(from:, via:, max_depth:, max_nodes:, max_edges:, direction: :out, deadline: nil)
|
|
81
|
+
NeighborhoodQuery.new(self, from:, via:, max_depth:, max_nodes:, max_edges:, direction:, deadline:).call
|
|
82
|
+
end
|
|
83
|
+
|
|
79
84
|
def insert_node(type, properties: {}, attrs: {}) = writer.insert_node(type, properties:, attrs:)
|
|
80
85
|
|
|
81
86
|
def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
Neighborhood = Data.define(:nodes, :edges, :nodes_truncated, :edges_truncated) do
|
|
5
|
+
def nodes_truncated? = nodes_truncated
|
|
6
|
+
def edges_truncated? = edges_truncated
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Reuses Reachable's bounded BFS, then selects the induced edges before
|
|
10
|
+
# hydrating either result. Probe nodes are excluded from the selected set.
|
|
11
|
+
class NeighborhoodQuery < Reachable
|
|
12
|
+
def initialize(context, max_edges:, **options)
|
|
13
|
+
unless max_edges.is_a?(Integer) && max_edges.between?(0, Pglpg.configuration.max_rows)
|
|
14
|
+
raise ArgumentError, "max_edges must be an Integer from 0 to max_rows"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@max_edges = max_edges
|
|
18
|
+
super(context, **options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
row = @context.read(compile, timeout: @timeout).fetch(0)
|
|
23
|
+
nodes = JSON.parse(row.fetch("nodes")).map { |data, depth| [@graph.build_node(data), depth].freeze }
|
|
24
|
+
edges = JSON.parse(row.fetch("edges")).map { @graph.build_edge(_1) }
|
|
25
|
+
Neighborhood.new(nodes: nodes.freeze, edges: edges.freeze,
|
|
26
|
+
nodes_truncated: row.fetch("nodes_truncated"), edges_truncated: row.fetch("edges_truncated"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def compile
|
|
30
|
+
sql = Sql.new(@graph.connection)
|
|
31
|
+
append_bfs(sql)
|
|
32
|
+
sql << ", candidates AS MATERIALIZED (SELECT b.depth, u.id, u.ord FROM bfs AS b"
|
|
33
|
+
sql << " CROSS JOIN LATERAL unnest(b.frontier) WITH ORDINALITY AS u(id, ord))"
|
|
34
|
+
sql << ", selected AS MATERIALIZED (SELECT id, depth FROM candidates ORDER BY depth, ord LIMIT "
|
|
35
|
+
sql.bind(@max_nodes, "bigint") << ")"
|
|
36
|
+
sql << ", selected_rows AS MATERIALIZED (SELECT n.* FROM selected AS s JOIN "
|
|
37
|
+
sql << @context.table_sql(@graph.nodes_table) << " AS n ON n.id = s.id AND "
|
|
38
|
+
@context.append_node_visible(sql, "n")
|
|
39
|
+
sql << ")"
|
|
40
|
+
append_edges(sql)
|
|
41
|
+
sql << " SELECT COALESCE((SELECT jsonb_agg(jsonb_build_array(to_jsonb(n), s.depth) ORDER BY s.depth, s.id)"
|
|
42
|
+
sql << " FROM selected AS s JOIN selected_rows AS n ON n.id = s.id), '[]') AS nodes,"
|
|
43
|
+
sql << " COALESCE((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id) FROM"
|
|
44
|
+
sql << " (SELECT * FROM edge_probe ORDER BY id LIMIT "
|
|
45
|
+
sql.bind(@max_edges, "bigint") << ") AS e), '[]') AS edges,"
|
|
46
|
+
sql << " (SELECT count(*) FROM candidates) > "
|
|
47
|
+
sql.bind(@max_nodes, "bigint") << " AS nodes_truncated,"
|
|
48
|
+
sql << " (SELECT count(*) FROM edge_probe) > "
|
|
49
|
+
sql.bind(@max_edges, "bigint") << " AS edges_truncated"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def neighbor_budget = @max_nodes - 1
|
|
55
|
+
|
|
56
|
+
def edge_types!(via, direction)
|
|
57
|
+
raise ArgumentError, "direction must be :out, :in or :both" unless DIRECTIONS.include?(direction)
|
|
58
|
+
|
|
59
|
+
types = (via.is_a?(Array) ? via : [via]).map { @graph.type.edge_type!(_1).name }.uniq
|
|
60
|
+
raise ArgumentError, "via needs at least one edge type" if types.empty?
|
|
61
|
+
|
|
62
|
+
types.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def append_steps(sql)
|
|
66
|
+
outgoing = @types.select { @direction != :in || @graph.type.edge_type!(_1).symmetric? }
|
|
67
|
+
incoming = @types.select { @direction != :out || @graph.type.edge_type!(_1).symmetric? }
|
|
68
|
+
steps = [[outgoing, "source_id", "target_id"], [incoming, "target_id", "source_id"]]
|
|
69
|
+
steps.reject { |types, _, _| types.empty? }.each_with_index do |(types, near, far), index|
|
|
70
|
+
sql << " UNION ALL " if index.positive?
|
|
71
|
+
append_step(sql, "e#{index}", near, far, types)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def append_edges(sql)
|
|
76
|
+
nodes = "selected_rows"
|
|
77
|
+
sql << ", edge_probe AS MATERIALIZED (SELECT e.* FROM " << @context.table_sql(@graph.edges_table) << " AS e"
|
|
78
|
+
sql << " JOIN selected AS s ON s.id = e.source_id JOIN selected AS t ON t.id = e.target_id"
|
|
79
|
+
sql << " JOIN " << nodes << " AS sn ON sn.id = s.id AND "
|
|
80
|
+
@context.append_node_visible(sql, "sn")
|
|
81
|
+
sql << " JOIN " << nodes << " AS tn ON tn.id = t.id AND "
|
|
82
|
+
@context.append_node_visible(sql, "tn")
|
|
83
|
+
sql << " WHERE e.type = ANY("
|
|
84
|
+
sql.bind_text_array(@types) << ") AND "
|
|
85
|
+
@context.append_edge_visible(sql, "e")
|
|
86
|
+
@context.append_temporal(sql, "e", @types.map { @graph.type.edge_type!(_1) })
|
|
87
|
+
sql << " ORDER BY e.id LIMIT "
|
|
88
|
+
sql.bind(@max_edges + 1, "bigint") << ")"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/pglpg/reachability.rb
CHANGED
|
@@ -43,6 +43,16 @@ module Pglpg
|
|
|
43
43
|
|
|
44
44
|
def compile
|
|
45
45
|
sql = Sql.new(@graph.connection)
|
|
46
|
+
append_bfs(sql)
|
|
47
|
+
sql << " SELECT b.depth, u.id FROM bfs AS b CROSS JOIN LATERAL unnest(b.frontier) WITH ORDINALITY AS u(id, ord)"
|
|
48
|
+
sql << " WHERE b.depth > 0 ORDER BY b.depth, u.ord"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def neighbor_budget = @max_nodes
|
|
54
|
+
|
|
55
|
+
def append_bfs(sql)
|
|
46
56
|
nodes = @context.table_sql(@graph.nodes_table)
|
|
47
57
|
sql << "WITH RECURSIVE bfs(depth, frontier, visited) AS ("
|
|
48
58
|
sql << "SELECT 0, ARRAY[s.id], ARRAY[s.id] FROM " << nodes << " AS s WHERE s.id = "
|
|
@@ -55,18 +65,14 @@ module Pglpg
|
|
|
55
65
|
@context.append_node_visible(sql, "n")
|
|
56
66
|
sql << " WHERE n.id <> ALL(b.visited) ORDER BY n.id LIMIT "
|
|
57
67
|
# remaining + 1, where remaining = max_nodes - (cardinality(visited) - 1)
|
|
58
|
-
sql.bind(
|
|
68
|
+
sql.bind(neighbor_budget, "int") << " - cardinality(b.visited) + 2) AS ids"
|
|
59
69
|
# Without OFFSET 0 the subquery is inlined into both references to
|
|
60
70
|
# nxt.ids and each level is computed twice.
|
|
61
71
|
sql << " OFFSET 0) AS nxt WHERE b.depth < "
|
|
62
72
|
sql.bind(@max_depth, "int") << " AND cardinality(b.frontier) > 0 AND cardinality(b.visited) - 1 <= "
|
|
63
|
-
sql.bind(
|
|
64
|
-
sql << " SELECT b.depth, u.id FROM bfs AS b CROSS JOIN LATERAL unnest(b.frontier) WITH ORDINALITY AS u(id, ord)"
|
|
65
|
-
sql << " WHERE b.depth > 0 ORDER BY b.depth, u.ord"
|
|
73
|
+
sql.bind(neighbor_budget, "int") << ")"
|
|
66
74
|
end
|
|
67
75
|
|
|
68
|
-
private
|
|
69
|
-
|
|
70
76
|
def max_rows = Pglpg.configuration.max_rows
|
|
71
77
|
|
|
72
78
|
# The far ends of visible edges leaving the frontier, as column "far". A
|
|
@@ -83,12 +89,12 @@ module Pglpg
|
|
|
83
89
|
end
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
def append_step(sql, e, near, far)
|
|
92
|
+
def append_step(sql, e, near, far, types = @types)
|
|
87
93
|
sql << "SELECT " << e << "." << far << " AS far FROM " << @context.table_sql(@graph.edges_table) << " AS " << e
|
|
88
94
|
sql << " WHERE " << e << "." << near << " = ANY(b.frontier) AND " << e << ".type = ANY("
|
|
89
|
-
sql.bind_text_array(
|
|
95
|
+
sql.bind_text_array(types) << ") AND "
|
|
90
96
|
@context.append_edge_visible(sql, e)
|
|
91
|
-
@context.append_temporal(sql, e,
|
|
97
|
+
@context.append_temporal(sql, e, types.map { @graph.type.edge_type!(_1) })
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
def edge_types!(via, direction)
|
data/lib/pglpg/testing.rb
CHANGED
|
@@ -67,6 +67,14 @@ module Pglpg
|
|
|
67
67
|
[r.nodes.map { |node, depth| [node.id, depth] }, r.complete, r.stopped_at_depth]
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
|
+
out[:neighborhoods] = refs.select(&:node?).map do |ref|
|
|
71
|
+
[0, 1, budget].uniq.map do |edge_budget|
|
|
72
|
+
r = ctx.neighborhood(from: ref, via: %i[WORKS_ON BASED_AT KNOWS FRIEND], direction: :both,
|
|
73
|
+
max_depth: 3, max_nodes: budget, max_edges: edge_budget)
|
|
74
|
+
[r.nodes.map { |node, depth| [element_data(node), depth] },
|
|
75
|
+
r.edges.map { element_data(_1) }, r.nodes_truncated?, r.edges_truncated?]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
70
78
|
out[:last_by_name] = element_data(m.().node(:p, :Person).order(p: { name: :desc }).first&.fetch(:p))
|
|
71
79
|
out[:find] = refs.map { element_data(ctx.find(_1)) }
|
|
72
80
|
out[:find_node] = %w[P1 P2 P3 H1].map { element_data(ctx.find_node(:Person, namespace: _1 == "H1" ? "hidden" : "visible", uid: _1)) }
|
data/lib/pglpg/version.rb
CHANGED
data/lib/pglpg.rb
CHANGED
|
@@ -25,6 +25,7 @@ require_relative "pglpg/writer"
|
|
|
25
25
|
require_relative "pglpg/query"
|
|
26
26
|
require_relative "pglpg/match_parser"
|
|
27
27
|
require_relative "pglpg/reachability"
|
|
28
|
+
require_relative "pglpg/neighborhood"
|
|
28
29
|
|
|
29
30
|
# Labeled property graphs stored in plain PostgreSQL tables.
|
|
30
31
|
module Pglpg
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pglpg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton Fefilov
|
|
@@ -60,6 +60,7 @@ files:
|
|
|
60
60
|
- lib/pglpg/model/edge.rb
|
|
61
61
|
- lib/pglpg/model/node.rb
|
|
62
62
|
- lib/pglpg/model/relation.rb
|
|
63
|
+
- lib/pglpg/neighborhood.rb
|
|
63
64
|
- lib/pglpg/query.rb
|
|
64
65
|
- lib/pglpg/reachability.rb
|
|
65
66
|
- lib/pglpg/ref.rb
|