pglpg 0.1.0 → 0.2.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 +157 -0
- data/README.md +65 -1
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +20 -7
- data/lib/pglpg/element.rb +16 -3
- data/lib/pglpg/graph.rb +8 -4
- data/lib/pglpg/model/base.rb +327 -0
- data/lib/pglpg/model/edge.rb +104 -0
- data/lib/pglpg/model/node.rb +202 -0
- data/lib/pglpg/model/relation.rb +327 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +47 -8
- data/lib/pglpg/reachability.rb +131 -0
- data/lib/pglpg/schema/edge_type.rb +14 -3
- data/lib/pglpg/schema/graph_type.rb +6 -5
- data/lib/pglpg/schema/node_type.rb +16 -7
- data/lib/pglpg/storage.rb +30 -16
- data/lib/pglpg/testing/leak_examples.rb +26 -5
- data/lib/pglpg/testing.rb +122 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +45 -10
- data/lib/pglpg.rb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ed878f97c7dcf3ffb630de85d798896dcbb6b0729e176ec410eb6383bd4659e
|
|
4
|
+
data.tar.gz: '055047480e44e5ff29ccaddce6d3ed730397dcffa243541c42e4c1e5b3c607ac'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 516ba2fc4c42f933616e5b2ffae77a4c320f6a1bd400dcd332addb5884f19499bd97d8ce8ceebdaafc9ceba3e52ed822abe5c994e2e9aa072b0ec615a2908ecf
|
|
7
|
+
data.tar.gz: 0707c16c9bc2e84093a9423531eb802460c291f61362f5af788074a356f46c264f83419bc815399360231d943ab3f181f51f09247a24442f5bcb02d77fc04b72
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,162 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- Symmetric edge types use `both`; `out` and `in` reject them, and `both`
|
|
8
|
+
rejects directed types. After adding a symmetric type, run
|
|
9
|
+
`Kg.storage.sync_constraints!` to install its canonical-endpoint constraint.
|
|
10
|
+
- Adopting scoped keys requires a required string scope property, both scope
|
|
11
|
+
and key in lookups and trusted upserts, and a transactional backfill with
|
|
12
|
+
`sync_constraints!` on the same connection. Interactive caller-chosen scoped
|
|
13
|
+
keys require an explicit `key_scope_allowed` occupancy grant. Existing
|
|
14
|
+
unscoped key declarations keep their contract.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- Database constraint failures on refused writes no longer expose the original
|
|
19
|
+
database exception through `Exception#cause`, including unscoped writes.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Scoped keys (ADR-0012): `key :uid, scope: :namespace` enforces a required
|
|
24
|
+
string namespace/key pair across the declaring type and subtypes. Interactive
|
|
25
|
+
caller-chosen keys require an explicit `key_scope_allowed` grant to learn
|
|
26
|
+
occupancy within that graph/domain/scope, including hidden collisions. The
|
|
27
|
+
grant gives no row access and has no SQL-time revocation guarantee. Trusted
|
|
28
|
+
upserts and `find_node` require both parts; scope and key remain immutable.
|
|
29
|
+
Existing schemas require a transactional backfill and constraint migration.
|
|
30
|
+
|
|
31
|
+
- Add `with_associations` to model relations for batched, visibility-aware eager loading (ADR-0011). Preloaded associations are materialized reads and can become stale; reload, save, or destroy clears that instance's cache. Each batch uses a separate database snapshot and raises `TooManyRows` when its distinct owner–target pairs exceed `max_rows`.
|
|
32
|
+
|
|
33
|
+
- Model layer, reads (ADR-0010; `require "pglpg/model"`):
|
|
34
|
+
- `Pglpg::Model::Node` and `Pglpg::Model::Edge` are ActiveModel classes
|
|
35
|
+
bound to a graph and a schema type with `graph Kg, type: :Person`. Their
|
|
36
|
+
attributes are the type's properties and the graph's application
|
|
37
|
+
columns. Assignment is strict: no form coercion.
|
|
38
|
+
- Declaring a model rejects a second model for the same type (a class of
|
|
39
|
+
the same name replaces it, for code reloading), a property named like an
|
|
40
|
+
application column or a model method, and a model extending another
|
|
41
|
+
model off the schema's inheritance.
|
|
42
|
+
- `Model.in(ctx)` returns a `Pglpg::Model::Relation` with `where`, `order`,
|
|
43
|
+
`limit`, `offset`, `count`, `exists?`, `first` and `find_by`. It compiles
|
|
44
|
+
to a `Pglpg::Query`, so visibility, ordering, `max_rows` and timeouts
|
|
45
|
+
apply as there. Edge relations return each symmetric edge once.
|
|
46
|
+
- `Relation#find(ref)` takes a `Ref` or its String form and raises
|
|
47
|
+
`Pglpg::Model::RecordNotFound` for a missing, hidden or wrong-type
|
|
48
|
+
element alike.
|
|
49
|
+
- A node loads as the model registered for its concrete type, else as the
|
|
50
|
+
queried model; `schema_type` names the stored type and
|
|
51
|
+
`extra_properties` holds properties the model doesn't declare.
|
|
52
|
+
- The shared leak suite observes models, relations and `find`.
|
|
53
|
+
- Model layer, writes (ADR-0010):
|
|
54
|
+
- `Relation#new` / `#build`, `#create` and `#create!` build records bound
|
|
55
|
+
to the relation's context. Edge records take `source:` and `target:` (a
|
|
56
|
+
node Ref or model); they can't change once stored.
|
|
57
|
+
- `save` / `save!` validate, then insert a new record or update only its
|
|
58
|
+
changed attributes through the context's guarded write API. `save`
|
|
59
|
+
returns false on validation errors; `save!` raises
|
|
60
|
+
`Pglpg::Model::RecordInvalid`. Refusals, timeouts and database errors
|
|
61
|
+
raise, and changes survive any failure. Without changes, no statement
|
|
62
|
+
runs.
|
|
63
|
+
- Schema validations: property values, required properties and required
|
|
64
|
+
columns. Keys are generated: an assigned key is an error on create, a
|
|
65
|
+
changed key on update. Abstract types can't be created.
|
|
66
|
+
- `save(writer:)` and `destroy(writer:)` accept only the record's own
|
|
67
|
+
context's trusted writer (`ArgumentError` otherwise), for trusted-only
|
|
68
|
+
writes such as pair-unique edges; it never permits assigning a key.
|
|
69
|
+
- `destroy` deletes an edge, or a node through the trusted writer only;
|
|
70
|
+
`destroyed?`.
|
|
71
|
+
- `reload` replaces attributes, timestamps and the dirty baseline, and
|
|
72
|
+
raises `RecordNotFound` without changing the record when the element
|
|
73
|
+
is missing or hidden.
|
|
74
|
+
- After a create, generated keys and timestamps are unknown until
|
|
75
|
+
`reload`. A caller's rollback is not tracked.
|
|
76
|
+
- Model layer, associations (ADR-0010 §5):
|
|
77
|
+
- `has_many name, :out | :in | :both, edge:, label: nil, model: nil` on a
|
|
78
|
+
node model defines a method returning a `Pglpg::Model::AssociationRelation`.
|
|
79
|
+
`:both` is for symmetric types, `:out` / `:in` for directed ones. The
|
|
80
|
+
target label defaults to the edge type's other endpoint and the model to
|
|
81
|
+
the one registered for it. Declaring checks the direction, that the
|
|
82
|
+
owner can be an endpoint, and name clashes; associations are inherited.
|
|
83
|
+
- A target query returns each visible target once: duplicates are removed
|
|
84
|
+
before `order`, `limit`, `offset`, `count` and `exists?`. `rel_where`
|
|
85
|
+
filters on edge properties ("at least one matching visible edge");
|
|
86
|
+
`rel_order` raises there.
|
|
87
|
+
- `with_rel` returns a `Pglpg::Model::BindingRelation`: one
|
|
88
|
+
`[target, edge]` pair per matching edge, counted and paginated as pairs,
|
|
89
|
+
with `rel_order`. It must come before `order`, `limit` or `offset`.
|
|
90
|
+
`each_with_rel` enumerates it. Edges load as the registered edge model.
|
|
91
|
+
- Associations need a saved owner, start from it (so its visibility
|
|
92
|
+
applies) and can't build records yet.
|
|
93
|
+
- The shared leak suite observes every association of every visible owner:
|
|
94
|
+
targets, counts, `exists?`, `first`, pagination, `rel_where` and pairs.
|
|
95
|
+
- Model layer, `pluck` (ADR-0010 §5):
|
|
96
|
+
- `Relation#pluck(*names)` returns attribute values (or `:id`, `:ref`) per
|
|
97
|
+
element, in the relation's order: a value for one name, an Array for
|
|
98
|
+
several. Elements are chosen first and projected afterwards, so two
|
|
99
|
+
targets with equal values give two values.
|
|
100
|
+
- `BindingRelation#pluck(*names, rel: [])` gives one entry per pair:
|
|
101
|
+
the target's names, then the edge's `rel` names.
|
|
102
|
+
- Unknown names raise `ArgumentError` before any query runs; `max_rows`
|
|
103
|
+
applies as for `to_a`.
|
|
104
|
+
- The shared leak suite also observes `pluck` on relations, associations
|
|
105
|
+
and pairs.
|
|
106
|
+
- `Context#reachable(from:, via:, max_depth:, max_nodes:, direction: :out, deadline: nil)`
|
|
107
|
+
returns a `Pglpg::Reachability`: visible nodes with their minimum depth,
|
|
108
|
+
ordered by depth then id, plus `complete` and `stopped_at_depth`.
|
|
109
|
+
- Runs as one recursive CTE that computes one BFS level per step (ADR-0009),
|
|
110
|
+
so it sees one snapshot at any isolation level.
|
|
111
|
+
- The start node is never returned; a hidden start and a missing one give
|
|
112
|
+
the same empty, complete result.
|
|
113
|
+
- `direction:` follows the `match` rule from ADR-0008, and a `via` list may
|
|
114
|
+
not mix directed and symmetric types.
|
|
115
|
+
- `max_nodes` may not exceed `max_rows`. `deadline` (seconds) can only
|
|
116
|
+
shorten the configured `statement_timeout`.
|
|
117
|
+
- The shared leak suite observes `reachable` from every visible node.
|
|
118
|
+
- `Context#read` takes a `timeout:` in milliseconds (internal), and every read
|
|
119
|
+
now runs in a savepoint, also with `statement_timeout: nil`. A timeout set
|
|
120
|
+
by the session itself no longer leaves the caller's transaction aborted.
|
|
121
|
+
- The shared leak suite keeps its `reachable` budgets within `max_rows`.
|
|
122
|
+
- The benchmark is rebuilt for CI (task 36367): seeded, versioned 1m,
|
|
123
|
+
hub-heavy and 10m fixtures with a manifest. Its cells cover viewers L2 and
|
|
124
|
+
L4, with starts each viewer can see, and include `reachable`. It gates
|
|
125
|
+
per-cell bounds in `bench/bounds.yml`, and compares buffers and latency
|
|
126
|
+
with a baseline revision. Buffers come from JSON plans and fail closed, and
|
|
127
|
+
a self-test proves that the gate fails. `bench/run.rb` is replaced by
|
|
128
|
+
`bench/measure.rb` and `bench/gate.rb`. A new `bench.yml` workflow runs the
|
|
129
|
+
full fixtures. The CI benchmark step no longer hides a failure behind
|
|
130
|
+
`tee`.
|
|
131
|
+
- Symmetric edge types (ADR-0008): `edge name, from:, to:, symmetric: true`,
|
|
132
|
+
with `allow_self_loops: true` to permit self-loops (off by default).
|
|
133
|
+
- Each edge is stored once with `source_id < target_id` (`<=` with self-loops),
|
|
134
|
+
enforced by a generated `CHECK`; `sync_constraints!` adds it. Writes accept
|
|
135
|
+
the endpoints in either order, and `WriteRequest#from` / `#to` carry the
|
|
136
|
+
canonical order. `unique: :pair` covers the unordered pair.
|
|
137
|
+
- One endpoint carries `from` and the other `to`, in either order.
|
|
138
|
+
- `Query#both` follows a symmetric edge from either end (GQL `~[ ]~`). With
|
|
139
|
+
no endpoint fixed, each edge binds once per orientation (once for a
|
|
140
|
+
self-loop); use `return(:edge).distinct` to list edges once.
|
|
141
|
+
- **Breaking:** `out` and `in` raise `ArgumentError` for symmetric types, and
|
|
142
|
+
`both` raises it for directed types. `symmetric:` no longer raises
|
|
143
|
+
`SchemaError`.
|
|
144
|
+
- `Edge#symmetric?` and `Edge#other(ref)`.
|
|
145
|
+
- The shared leak suite adds symmetric cases: hidden bridges and hidden
|
|
146
|
+
nodes stored at either end of an edge.
|
|
147
|
+
- The benchmark adds symmetric 1- and 2-hop shapes and checks the buffers of
|
|
148
|
+
the symmetric hub query.
|
|
149
|
+
|
|
150
|
+
### Fixed
|
|
151
|
+
|
|
152
|
+
- Storage DDL is atomic: `sync_constraints!` and the entire `create!` operation
|
|
153
|
+
use a transaction on their supplied connection, or a savepoint in an existing
|
|
154
|
+
ActiveRecord transaction. A failed call restores its DDL and leaves a healthy
|
|
155
|
+
caller transaction usable. Outside a transaction, each call commits its own
|
|
156
|
+
transaction. Application backfills still need an explicit outer transaction
|
|
157
|
+
on the same connection to be rolled back with a failed schema transition.
|
|
158
|
+
|
|
159
|
+
|
|
3
160
|
## 0.1.0 (2026-10-28)
|
|
4
161
|
|
|
5
162
|
First version, following the accepted 0.1 baseline (knowen.md bb topic 145).
|
data/README.md
CHANGED
|
@@ -8,10 +8,22 @@ 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.
|
|
11
|
+
Status: **0.2.0 release candidate.** Publication awaits the
|
|
12
|
+
[release gate](https://knowen.md/nodes/36368) and maintainer review. The design and its decisions live in the
|
|
12
13
|
[pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
|
|
13
14
|
ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
|
|
14
15
|
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
After publication:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "pglpg", "~> 0.2.0"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Before 1.0, minor releases may break compatibility. Read the
|
|
25
|
+
[0.2.0 upgrade notes](CHANGELOG.md) before upgrading from 0.1.
|
|
26
|
+
|
|
15
27
|
## Define a graph
|
|
16
28
|
|
|
17
29
|
```ruby
|
|
@@ -36,9 +48,14 @@ Kg = Pglpg::Graph.define(:kg) do
|
|
|
36
48
|
property :role, :string
|
|
37
49
|
end
|
|
38
50
|
edge :KNOWS, from: :Person, to: :Person
|
|
51
|
+
edge :FRIEND, from: :Person, to: :Person, symmetric: true # undirected
|
|
39
52
|
end
|
|
40
53
|
```
|
|
41
54
|
|
|
55
|
+
A `symmetric: true` edge type is undirected: each edge is stored once, smaller
|
|
56
|
+
node id first, and writes accept the endpoints in either order. Self-loops are
|
|
57
|
+
refused unless the type sets `allow_self_loops: true`.
|
|
58
|
+
|
|
42
59
|
Property types: `string integer float decimal boolean date datetime enum list`
|
|
43
60
|
(`enum` takes `values:`, `list` takes `of:`). Types are closed: undeclared
|
|
44
61
|
properties are rejected. A nil value means the property is absent.
|
|
@@ -118,6 +135,10 @@ rows.first[:q] # => #<Pglpg::Node ...>
|
|
|
118
135
|
g.find(ref) # visible node or edge, else nil
|
|
119
136
|
g.find_node(:Person, uid: "PER-12")
|
|
120
137
|
query.count; query.exists?; query.first; query.distinct; query.explain(analyze: true)
|
|
138
|
+
|
|
139
|
+
# Symmetric edge types use both (out and in refuse them). Each edge binds once
|
|
140
|
+
# per orientation, so list edges once with return(:f).distinct.
|
|
141
|
+
g.match.node(:p, ref: ann).both(:FRIEND, :q, :Person, edge: :f)
|
|
121
142
|
```
|
|
122
143
|
|
|
123
144
|
Conditions are structured only: a value (equality), an array (one of), nil
|
|
@@ -125,6 +146,49 @@ Conditions are structured only: a value (equality), an array (one of), nil
|
|
|
125
146
|
raise `Pglpg::TooManyRows` beyond `max_rows` (default 1000); reads run under
|
|
126
147
|
`statement_timeout` (default 5 s) and raise `Pglpg::QueryTimeout`.
|
|
127
148
|
|
|
149
|
+
## Reachability
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
r = g.reachable(from: ann, via: %i[KNOWS], max_depth: 3, max_nodes: 500, deadline: 2.0)
|
|
153
|
+
r.nodes # => [[ref, 1], [ref, 1], [ref, 2], …] by depth, then id
|
|
154
|
+
r.complete? # false when more visible nodes are reachable within max_depth
|
|
155
|
+
r.stopped_at_depth # depth of the last returned node when incomplete, else nil
|
|
156
|
+
|
|
157
|
+
g.reachable(from: ann, via: %i[KNOWS], direction: :in, max_depth: 2, max_nodes: 100)
|
|
158
|
+
g.reachable(from: ann, via: %i[FRIEND], direction: :both, max_depth: 2, max_nodes: 100)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Each visible node comes once, at its minimum depth; the start node never does.
|
|
162
|
+
Hidden nodes and edges are never traversed, and a hidden or missing start gives
|
|
163
|
+
an empty, complete result. `via` lists edge types that may be followed at any
|
|
164
|
+
hop. `direction:` follows the same rule as `match`: `:both` takes only
|
|
165
|
+
symmetric types, `:out` (the default) and `:in` only directed ones.
|
|
166
|
+
`max_nodes` may not exceed `max_rows`.
|
|
167
|
+
|
|
168
|
+
The traversal is one SQL statement (ADR-0009), so it sees one snapshot at any
|
|
169
|
+
isolation level. `deadline` is in seconds and can only shorten the configured
|
|
170
|
+
`statement_timeout`; with no timeout configured it applies alone. Running out
|
|
171
|
+
of time raises `Pglpg::QueryTimeout` and leaves your transaction usable.
|
|
172
|
+
|
|
173
|
+
## Models and eager loading
|
|
174
|
+
|
|
175
|
+
`require "pglpg/model"` adds optional ActiveModel records bound to a graph and
|
|
176
|
+
schema type. Context-bound relations support reads, guarded saves, associations,
|
|
177
|
+
`pluck` and visibility-aware batched eager loading with `with_associations`.
|
|
178
|
+
See the [API contract](https://knowen.md/nodes/36329) for examples, cache lifetime
|
|
179
|
+
and row limits.
|
|
180
|
+
|
|
181
|
+
## Scoped keys
|
|
182
|
+
|
|
183
|
+
A type can declare `key :uid, scope: :namespace` when both properties are
|
|
184
|
+
required strings. Uniqueness covers the scope/key pair across the type and its
|
|
185
|
+
subtypes. `find_node` and trusted upserts require both values. An interactive
|
|
186
|
+
caller-chosen key needs an explicit `key_scope_allowed` grant: it permits
|
|
187
|
+
learning occupancy in that scope, including hidden collisions, without granting
|
|
188
|
+
row access. Scope and key are immutable. Existing data needs a transactional
|
|
189
|
+
backfill and constraint migration; see the
|
|
190
|
+
[API contract](https://knowen.md/nodes/36329).
|
|
191
|
+
|
|
128
192
|
## Leak specs for your policy
|
|
129
193
|
|
|
130
194
|
```ruby
|
data/lib/pglpg/configuration.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Pglpg
|
|
4
4
|
# Global settings. Each graph can override visibility, write_policy and
|
|
5
|
-
# trusted_writer_allowed when it is defined.
|
|
5
|
+
# trusted_writer_allowed and key_scope_allowed when it is defined.
|
|
6
6
|
class Configuration
|
|
7
7
|
# Required. An object with +node(table, viewer)+ and +edge(table, viewer)+,
|
|
8
8
|
# each returning an Arel condition on the given Arel::Table.
|
|
@@ -16,6 +16,9 @@ module Pglpg
|
|
|
16
16
|
# may be used for that context. Unset means the capability is never granted.
|
|
17
17
|
attr_accessor :trusted_writer_allowed
|
|
18
18
|
|
|
19
|
+
# Explicit permission to learn key occupancy in a scoped key domain.
|
|
20
|
+
attr_accessor :key_scope_allowed
|
|
21
|
+
|
|
19
22
|
# The ActiveRecord class whose connection pglpg uses.
|
|
20
23
|
attr_accessor :connection_class
|
|
21
24
|
|
data/lib/pglpg/context.rb
CHANGED
|
@@ -40,14 +40,25 @@ module Pglpg
|
|
|
40
40
|
# The visible node of +type+ (or a subtype) with the given key, or nil.
|
|
41
41
|
# ctx.find_node(:Person, uid: "PER-12")
|
|
42
42
|
def find_node(type, **key)
|
|
43
|
+
key = key.transform_keys(&:to_sym)
|
|
43
44
|
node_type = graph.type.node_type!(type)
|
|
44
|
-
unless node_type.key && key.keys ==
|
|
45
|
-
raise ArgumentError, "#{type} is looked up by #{node_type.key&.
|
|
45
|
+
unless node_type.key && key.keys.sort == node_type.key.properties.sort
|
|
46
|
+
raise ArgumentError, "#{type} is looked up by #{node_type.key&.properties.inspect}"
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
match.node(:n, type, **key).first&.fetch(:n)
|
|
49
50
|
end
|
|
50
51
|
|
|
52
|
+
# Visible nodes reachable from +from+ within +max_depth+ hops over the
|
|
53
|
+
# edge types in +via+, each with its minimum depth (a Reachability).
|
|
54
|
+
# Hidden nodes and edges are never traversed. The traversal is one
|
|
55
|
+
# statement; +deadline+ (seconds) can only shorten the configured
|
|
56
|
+
# statement timeout. See ADR-0009.
|
|
57
|
+
# ctx.reachable(from: ref, via: %i[KNOWS], max_depth: 3, max_nodes: 500)
|
|
58
|
+
def reachable(from:, via:, max_depth:, max_nodes:, direction: :out, deadline: nil)
|
|
59
|
+
Reachable.new(self, from:, via:, direction:, max_depth:, max_nodes:, deadline:).call
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def insert_node(type, properties: {}, attrs: {}) = writer.insert_node(type, properties:, attrs:)
|
|
52
63
|
|
|
53
64
|
def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
|
|
@@ -88,13 +99,15 @@ module Pglpg
|
|
|
88
99
|
sql << ")"
|
|
89
100
|
end
|
|
90
101
|
|
|
91
|
-
# Runs a read under the configured statement
|
|
92
|
-
|
|
102
|
+
# Runs a read under +timeout+ (ms; default: the configured statement
|
|
103
|
+
# timeout). nil leaves the session's timeout as it is. The read always
|
|
104
|
+
# runs in a savepoint, so a timeout (ours or the session's) never leaves
|
|
105
|
+
# the caller's transaction aborted.
|
|
106
|
+
def read(sql, timeout: Pglpg.configuration.statement_timeout)
|
|
93
107
|
graph.with_connection do |c|
|
|
94
|
-
timeout = Pglpg.configuration.statement_timeout
|
|
95
|
-
next c.select_all(sql.to_s, "Pglpg", sql.binds).to_a if timeout.nil?
|
|
96
|
-
|
|
97
108
|
c.transaction(requires_new: true) do
|
|
109
|
+
next c.select_all(sql.to_s, "Pglpg", sql.binds).to_a if timeout.nil?
|
|
110
|
+
|
|
98
111
|
previous = c.select_value("SELECT current_setting('statement_timeout')")
|
|
99
112
|
c.select_value("SELECT set_config('statement_timeout', '#{Integer(timeout)}', true)")
|
|
100
113
|
rows = c.select_all(sql.to_s, "Pglpg", sql.binds).to_a
|
data/lib/pglpg/element.rb
CHANGED
|
@@ -31,11 +31,13 @@ module Pglpg
|
|
|
31
31
|
def inspect = "#<Pglpg::Node #{ref} #{key_label} #{properties.inspect}>"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# An edge read from the graph. Values are frozen.
|
|
34
|
+
# An edge read from the graph. Values are frozen. For a symmetric edge type,
|
|
35
|
+
# source and target are the stored (canonical) endpoints and carry no
|
|
36
|
+
# direction; use #other to walk from one end to the other.
|
|
35
37
|
class Edge
|
|
36
38
|
attr_reader :ref, :type, :source, :target, :properties, :attrs, :created_at, :updated_at
|
|
37
39
|
|
|
38
|
-
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:)
|
|
40
|
+
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:, symmetric: false)
|
|
39
41
|
@ref = ref
|
|
40
42
|
@type = type
|
|
41
43
|
@source = source
|
|
@@ -44,6 +46,7 @@ module Pglpg
|
|
|
44
46
|
@attrs = attrs
|
|
45
47
|
@created_at = created_at
|
|
46
48
|
@updated_at = updated_at
|
|
49
|
+
@symmetric = symmetric
|
|
47
50
|
freeze
|
|
48
51
|
end
|
|
49
52
|
|
|
@@ -51,12 +54,22 @@ module Pglpg
|
|
|
51
54
|
|
|
52
55
|
def [](name) = properties[name.to_sym]
|
|
53
56
|
|
|
57
|
+
def symmetric? = @symmetric
|
|
58
|
+
|
|
59
|
+
# The endpoint opposite +ref+ (the same node for a self-loop).
|
|
60
|
+
def other(ref)
|
|
61
|
+
return target if ref == source
|
|
62
|
+
return source if ref == target
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "#{ref} is not an endpoint of #{self.ref}"
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
def ==(other) = other.is_a?(Edge) && other.ref == ref
|
|
55
68
|
|
|
56
69
|
alias eql? ==
|
|
57
70
|
|
|
58
71
|
def hash = ref.hash
|
|
59
72
|
|
|
60
|
-
def inspect = "#<Pglpg::Edge #{ref} #{type} #{source.id}
|
|
73
|
+
def inspect = "#<Pglpg::Edge #{ref} #{type} #{source.id}#{symmetric? ? "--" : "->"}#{target.id} #{properties.inspect}>"
|
|
61
74
|
end
|
|
62
75
|
end
|
data/lib/pglpg/graph.rb
CHANGED
|
@@ -18,15 +18,15 @@ module Pglpg
|
|
|
18
18
|
attr_reader :name, :type, :storage
|
|
19
19
|
|
|
20
20
|
def self.define(name, nodes_table: nil, edges_table: nil, visibility: nil, write_policy: nil,
|
|
21
|
-
trusted_writer_allowed: nil, connection_class: nil, &block)
|
|
21
|
+
trusted_writer_allowed: nil, key_scope_allowed: nil, connection_class: nil, &block)
|
|
22
22
|
type = Schema::GraphType.new
|
|
23
23
|
Schema::Builder.new(type).instance_eval(&block) if block
|
|
24
24
|
type.resolve!
|
|
25
25
|
new(name, type, nodes_table: nodes_table || "#{name}_nodes", edges_table: edges_table || "#{name}_edges",
|
|
26
|
-
visibility:, write_policy:, trusted_writer_allowed:, connection_class:)
|
|
26
|
+
visibility:, write_policy:, trusted_writer_allowed:, key_scope_allowed:, connection_class:)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(name, type, nodes_table:, edges_table:, visibility:, write_policy:, trusted_writer_allowed:,
|
|
29
|
+
def initialize(name, type, nodes_table:, edges_table:, visibility:, write_policy:, trusted_writer_allowed:, key_scope_allowed:,
|
|
30
30
|
connection_class:)
|
|
31
31
|
@name = Schema.check_name!(name, "graph")
|
|
32
32
|
@type = type
|
|
@@ -34,6 +34,7 @@ module Pglpg
|
|
|
34
34
|
@visibility = visibility
|
|
35
35
|
@write_policy = write_policy
|
|
36
36
|
@trusted_writer_allowed = trusted_writer_allowed
|
|
37
|
+
@key_scope_allowed = key_scope_allowed
|
|
37
38
|
@connection_class = connection_class
|
|
38
39
|
freeze
|
|
39
40
|
end
|
|
@@ -77,6 +78,8 @@ module Pglpg
|
|
|
77
78
|
|
|
78
79
|
def trusted_writer_allowed = @trusted_writer_allowed || Pglpg.configuration.trusted_writer_allowed
|
|
79
80
|
|
|
81
|
+
def key_scope_allowed = @key_scope_allowed.nil? ? Pglpg.configuration.key_scope_allowed : @key_scope_allowed
|
|
82
|
+
|
|
80
83
|
def connection_class = @connection_class || Pglpg.configuration.connection_class || ActiveRecord::Base
|
|
81
84
|
|
|
82
85
|
def with_connection(&) = connection_class.with_connection(&)
|
|
@@ -111,7 +114,8 @@ module Pglpg
|
|
|
111
114
|
source: ref(:node, row["source_id"]), target: ref(:node, row["target_id"]),
|
|
112
115
|
properties: edge_type ? edge_type.decode_properties(row["properties"]) : row["properties"].transform_keys(&:to_sym).freeze,
|
|
113
116
|
attrs: decode_attrs(type.edge_columns, row),
|
|
114
|
-
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"])
|
|
117
|
+
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"]),
|
|
118
|
+
symmetric: edge_type&.symmetric? || false
|
|
115
119
|
)
|
|
116
120
|
end
|
|
117
121
|
|