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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcdce167b8e343909f06e710fdb65a1833a5cfa3649c85a475050c830a463a0d
4
- data.tar.gz: f7725ba5bd6255af98a77875b766ac0e7d3a180f4af7ced79b50446998fbda6d
3
+ metadata.gz: 0d0ce8830ee927b0b6f98d9a8601ce9f86bb3bea4570de038f6274f9f1008884
4
+ data.tar.gz: d0ba5b1cf855c1e9324bbbae62dd204248c34344944a2848a721af581b58580e
5
5
  SHA512:
6
- metadata.gz: e536165dfa69606c33d7c15c4dd77c68878a3be01b6f8b7b097e4effd3597d26a6b65e2dd1c87d2ce5247c588980bebb779af6e674d4e29d84f3efcf05187b22
7
- data.tar.gz: 7c6fbe3e465c7134ae05ec5601e61f941462687f60f1dd91242641b1ab22446e44d0f9836c3db4cb2ca6c330f0136ead5cd8c23c305eac300ff9d15374aa645a
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.4.0 release candidate**, with a read-only text MATCH parser.
12
- The latest published version is 0.3.0. The design and its decisions live in the
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.3.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
- After 0.4.0 is published, use `gem "pglpg", "~> 0.4.0"`.
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
@@ -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(@max_nodes, "int") << " - cardinality(b.visited) + 2) AS ids"
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(@max_nodes, "int") << ")"
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(@types) << ") AND "
95
+ sql.bind_text_array(types) << ") AND "
90
96
  @context.append_edge_visible(sql, e)
91
- @context.append_temporal(sql, e, @types.map { @graph.type.edge_type!(_1) })
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pglpg
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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.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