pglpg 0.1.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 +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/lib/pglpg/condition.rb +33 -0
- data/lib/pglpg/configuration.rb +48 -0
- data/lib/pglpg/context.rb +120 -0
- data/lib/pglpg/element.rb +62 -0
- data/lib/pglpg/errors.rb +40 -0
- data/lib/pglpg/graph.rb +132 -0
- data/lib/pglpg/query.rb +411 -0
- data/lib/pglpg/ref.rb +25 -0
- data/lib/pglpg/schema/column.rb +38 -0
- data/lib/pglpg/schema/edge_type.rb +35 -0
- data/lib/pglpg/schema/element_type.rb +65 -0
- data/lib/pglpg/schema/graph_type.rb +100 -0
- data/lib/pglpg/schema/node_type.rb +92 -0
- data/lib/pglpg/schema/property.rb +123 -0
- data/lib/pglpg/sql.rb +75 -0
- data/lib/pglpg/storage.rb +208 -0
- data/lib/pglpg/testing/leak_examples.rb +121 -0
- data/lib/pglpg/testing.rb +64 -0
- data/lib/pglpg/version.rb +5 -0
- data/lib/pglpg/writer.rb +329 -0
- data/lib/pglpg.rb +27 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 64d4bfb95eeacd8297e72c8dc02b95bcda6354e5c3c157655483c13d78e9b9a8
|
|
4
|
+
data.tar.gz: c75cbedcd42f7fe07265f8aecfea1e78adc5e44f3651587b069955ea6c47f254
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1b0d31c4236eac2f7b3cb22b65d7650c1a01d3687ea5505b44e64b540e54f9d6e880344e2bbc1f90a6bb1512dcfdaad5c06ea0dc58ea621f768e82c50926e513
|
|
7
|
+
data.tar.gz: a8bcb6da10e7779df9409d7dc5923a0f172177d4d2acb8f695a347adf89019bc1d04b4a5187b8c85560b7043b171bfc0bc464c1256189c4ae43db59d5c4f0a5d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
First version, following the accepted 0.1 baseline (knowen.md bb topic 145).
|
|
6
|
+
Requires Ruby ≥ 4.0, ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18.
|
|
7
|
+
|
|
8
|
+
- Graph type DSL: node types with inheritance and abstract types, edge types,
|
|
9
|
+
typed properties (closed types), one string key per type, `unique: :pair`
|
|
10
|
+
edge types, application columns.
|
|
11
|
+
- Storage: two tables per graph, generated constraints (endpoint foreign keys
|
|
12
|
+
with `ON DELETE RESTRICT`, keys unique across a type and its subtypes, pair
|
|
13
|
+
uniqueness, typed required-property checks), `sync_constraints!`,
|
|
14
|
+
`label_drift` and `relabel!`.
|
|
15
|
+
- `Pglpg::Ref`: immutable, graph-scoped element references.
|
|
16
|
+
- `Graph#for(viewer:)` as the only entry point.
|
|
17
|
+
- Writes: interactive inserts with generated keys, updates and edge deletes;
|
|
18
|
+
a trusted writer (granted by the application) for caller-chosen keys,
|
|
19
|
+
upserts and node deletes. The write policy checks the request in Ruby;
|
|
20
|
+
visibility and the policy's conditions on the stored row run inside the
|
|
21
|
+
statement; edge endpoints are checked and locked `FOR SHARE`.
|
|
22
|
+
- Fixed-length pattern queries with structured conditions, ordering,
|
|
23
|
+
pagination, `count`, `exists?`, `first`, `distinct` and typed results.
|
|
24
|
+
- Shared leak specs (`it_behaves_like "a pglpg visibility policy"`).
|
|
25
|
+
- Benchmark harness with time and buffer limits.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 valamon.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# pglpg
|
|
2
|
+
|
|
3
|
+
Labeled property graphs stored in plain PostgreSQL tables, for Ruby.
|
|
4
|
+
|
|
5
|
+
pglpg follows the ISO GQL property graph model and PG-Schema / PG-Keys. It
|
|
6
|
+
needs no PostgreSQL extension and no second database: each graph is two
|
|
7
|
+
ordinary tables, written inside your own transactions. Every query applies
|
|
8
|
+
your visibility policy to every node and edge on every hop, so hidden records
|
|
9
|
+
never act as bridges and never show up in counts or limits.
|
|
10
|
+
|
|
11
|
+
Status: **0.1, pre-release.** The design and its decisions live in the
|
|
12
|
+
[pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
|
|
13
|
+
ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
|
|
14
|
+
|
|
15
|
+
## Define a graph
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Kg = Pglpg::Graph.define(:kg) do
|
|
19
|
+
# Application columns: real, indexed columns your policies filter on.
|
|
20
|
+
column :classification, :integer, null: false, covering: true
|
|
21
|
+
|
|
22
|
+
node :Person do
|
|
23
|
+
property :uid, :string, required: true
|
|
24
|
+
property :name, :string
|
|
25
|
+
property :born, :date
|
|
26
|
+
key :uid # unique across Person and its subtypes
|
|
27
|
+
end
|
|
28
|
+
node :Employee, extends: :Person # labels {Employee, Person}
|
|
29
|
+
node :Project do
|
|
30
|
+
property :uid, :string, required: true
|
|
31
|
+
property :headcount, :integer
|
|
32
|
+
key :uid, generate: ->(_type) { "PRJ-#{SecureRandom.hex(6)}" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
edge :WORKS_ON, from: :Person, to: :Project, unique: :pair do
|
|
36
|
+
property :role, :string
|
|
37
|
+
end
|
|
38
|
+
edge :KNOWS, from: :Person, to: :Person
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Property types: `string integer float decimal boolean date datetime enum list`
|
|
43
|
+
(`enum` takes `values:`, `list` takes `of:`). Types are closed: undeclared
|
|
44
|
+
properties are rejected. A nil value means the property is absent.
|
|
45
|
+
|
|
46
|
+
In a migration:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
def up = Kg.storage.create! # tables, indexes, constraints
|
|
50
|
+
def down = Kg.storage.drop!
|
|
51
|
+
# After changing the graph type:
|
|
52
|
+
Kg.storage.sync_constraints!
|
|
53
|
+
Kg.storage.relabel! # when inherited labels changed
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`Kg.storage.label_drift` returns `{}` when stored labels match the schema;
|
|
57
|
+
put it in a spec.
|
|
58
|
+
|
|
59
|
+
## Policies
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Pglpg.configure do |c|
|
|
63
|
+
c.visibility = ClearancePolicy # required for reads
|
|
64
|
+
c.write_policy = ClearanceWritePolicy # required for writes
|
|
65
|
+
c.trusted_writer_allowed = ->(ctx) { ctx.viewer.importer? } # off when unset
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module ClearancePolicy # Arel conditions, applied to every alias
|
|
69
|
+
def self.node(table, viewer) = table[:classification].lteq(viewer.clearance)
|
|
70
|
+
def self.edge(table, viewer) = table[:classification].lteq(viewer.clearance)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module ClearanceWritePolicy
|
|
74
|
+
# The requested operation and new state (a Pglpg::WriteRequest).
|
|
75
|
+
def self.authorize?(req) = req.attrs.fetch(:classification, 0) <= req.viewer.clearance
|
|
76
|
+
|
|
77
|
+
# Optional: a condition on the stored row, checked inside the statement
|
|
78
|
+
# (for example, allowed old -> new transitions).
|
|
79
|
+
def self.existing(table, req) = nil
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Policies can also be passed per graph to `Graph.define`.
|
|
84
|
+
|
|
85
|
+
## Write
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
g = Kg.for(viewer: current_viewer)
|
|
89
|
+
|
|
90
|
+
ann = g.insert_node(:Person, properties: { name: "Ann" }, attrs: { classification: 1 })
|
|
91
|
+
# => #<Pglpg::Ref kg:node:1> (uid is generated; passing one raises ArgumentError)
|
|
92
|
+
g.update_node(ann, properties: { born: Date.new(1990, 1, 1) })
|
|
93
|
+
g.insert_edge(:KNOWS, from: ann, to: bob, attrs: { classification: 1 })
|
|
94
|
+
|
|
95
|
+
w = g.trusted_writer # raises NotTrusted unless granted
|
|
96
|
+
p = w.upsert_node(:Person, key: { uid: "PER-12" }, properties: { name: "Bob" }, attrs: { classification: 1 })
|
|
97
|
+
w.upsert_edge(:WORKS_ON, from: p, to: project, properties: { role: "lead" }, attrs: { classification: 1 })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A refused write raises `Pglpg::WriteRejected` with no Ref and no row data,
|
|
101
|
+
whether the target is hidden, missing or not allowed. Writes run in a
|
|
102
|
+
savepoint, so a refusal never aborts your transaction.
|
|
103
|
+
|
|
104
|
+
## Query
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
rows = g.match
|
|
108
|
+
.node(:p, :Person, uid: "PER-12")
|
|
109
|
+
.out(:WORKS_ON, :j, :Project, edge: :w)
|
|
110
|
+
.in(:WORKS_ON, :q, :Person, from: :j) # branch from an earlier variable
|
|
111
|
+
.where(j: { headcount: Pglpg.gt(0) }, w: { role: "lead" })
|
|
112
|
+
.return(:q, :j)
|
|
113
|
+
.order(q: :name)
|
|
114
|
+
.limit(50)
|
|
115
|
+
.to_a
|
|
116
|
+
rows.first[:q] # => #<Pglpg::Node ...>
|
|
117
|
+
|
|
118
|
+
g.find(ref) # visible node or edge, else nil
|
|
119
|
+
g.find_node(:Person, uid: "PER-12")
|
|
120
|
+
query.count; query.exists?; query.first; query.distinct; query.explain(analyze: true)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Conditions are structured only: a value (equality), an array (one of), nil
|
|
124
|
+
(absent), or `Pglpg.gt / gte / lt / lte / ne / in`. Queries without a limit
|
|
125
|
+
raise `Pglpg::TooManyRows` beyond `max_rows` (default 1000); reads run under
|
|
126
|
+
`statement_timeout` (default 5 s) and raise `Pglpg::QueryTimeout`.
|
|
127
|
+
|
|
128
|
+
## Leak specs for your policy
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
require "pglpg/testing"
|
|
132
|
+
|
|
133
|
+
RSpec.describe ClearancePolicy do
|
|
134
|
+
it_behaves_like "a pglpg visibility policy" do
|
|
135
|
+
let(:pglpg_visibility) { ClearancePolicy }
|
|
136
|
+
let(:pglpg_viewer) { Viewer.new(clearance: 2) }
|
|
137
|
+
let(:pglpg_columns) { { classification: [:integer, { null: false }] } }
|
|
138
|
+
let(:pglpg_visible_attrs) { { classification: 2 } }
|
|
139
|
+
let(:pglpg_hidden_attrs) { { classification: 3 } }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
createdb pglpg_test
|
|
148
|
+
bundle install
|
|
149
|
+
bundle exec rspec
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`PGLPG_DATABASE_URL` overrides the test database (use one per worktree).
|
|
153
|
+
The benchmark is described in [bench/README.md](bench/README.md).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# A structured property condition. Plain values mean equality, arrays mean
|
|
5
|
+
# "one of", nil means "absent". Use Pglpg.gt(3) and friends for the rest.
|
|
6
|
+
Condition = Data.define(:op, :value) do
|
|
7
|
+
def self.wrap(value)
|
|
8
|
+
case value
|
|
9
|
+
when Condition then value
|
|
10
|
+
when nil then new(op: :absent, value: nil)
|
|
11
|
+
when Array then new(op: :in, value:)
|
|
12
|
+
when Range then raise ArgumentError, "ranges are not supported; combine Pglpg.gte and Pglpg.lte"
|
|
13
|
+
else new(op: :eq, value:)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Condition::OPS = { gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "<>" }.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def gt(value) = Condition.new(op: :gt, value:)
|
|
22
|
+
|
|
23
|
+
def gte(value) = Condition.new(op: :gte, value:)
|
|
24
|
+
|
|
25
|
+
def lt(value) = Condition.new(op: :lt, value:)
|
|
26
|
+
|
|
27
|
+
def lte(value) = Condition.new(op: :lte, value:)
|
|
28
|
+
|
|
29
|
+
def ne(value) = Condition.new(op: :ne, value:)
|
|
30
|
+
|
|
31
|
+
def in(values) = Condition.new(op: :in, value: Array(values))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# Global settings. Each graph can override visibility, write_policy and
|
|
5
|
+
# trusted_writer_allowed when it is defined.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Required. An object with +node(table, viewer)+ and +edge(table, viewer)+,
|
|
8
|
+
# each returning an Arel condition on the given Arel::Table.
|
|
9
|
+
attr_accessor :visibility
|
|
10
|
+
|
|
11
|
+
# Required for writes. An object with +authorize?(request)+ and, optionally,
|
|
12
|
+
# +existing(table, request)+ returning an Arel condition on the stored row.
|
|
13
|
+
attr_accessor :write_policy
|
|
14
|
+
|
|
15
|
+
# A callable +->(context) { ... }+ returning true when the trusted writer
|
|
16
|
+
# may be used for that context. Unset means the capability is never granted.
|
|
17
|
+
attr_accessor :trusted_writer_allowed
|
|
18
|
+
|
|
19
|
+
# The ActiveRecord class whose connection pglpg uses.
|
|
20
|
+
attr_accessor :connection_class
|
|
21
|
+
|
|
22
|
+
# Queries without an explicit limit raise TooManyRows above this many rows.
|
|
23
|
+
attr_accessor :max_rows
|
|
24
|
+
|
|
25
|
+
# Statement timeout for reads, in milliseconds. nil leaves the session value.
|
|
26
|
+
attr_accessor :statement_timeout
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@connection_class = nil
|
|
30
|
+
@max_rows = 1000
|
|
31
|
+
@statement_timeout = 5000
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def configuration
|
|
37
|
+
@configuration ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def configure
|
|
41
|
+
yield configuration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_configuration!
|
|
45
|
+
@configuration = Configuration.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# A graph seen by one viewer. Created by Graph#for; immutable. Every query
|
|
5
|
+
# and write built from it applies the graph's policies for this viewer.
|
|
6
|
+
class Context
|
|
7
|
+
attr_reader :graph, :viewer
|
|
8
|
+
|
|
9
|
+
def initialize(graph, viewer)
|
|
10
|
+
@graph = graph
|
|
11
|
+
@viewer = viewer
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Starts a fixed-length pattern query.
|
|
16
|
+
def match = Query.new(self)
|
|
17
|
+
|
|
18
|
+
# The visible node or edge behind +ref+, or nil. A hidden element and a
|
|
19
|
+
# missing one look the same.
|
|
20
|
+
def find(ref)
|
|
21
|
+
raise InvalidRef, "expected a Pglpg::Ref" unless ref.is_a?(Ref)
|
|
22
|
+
|
|
23
|
+
graph.check_ref!(ref, ref.kind)
|
|
24
|
+
return match.node(:n, ref:).first&.fetch(:n) if ref.node?
|
|
25
|
+
|
|
26
|
+
sql = Sql.new(graph.connection)
|
|
27
|
+
e = "e"
|
|
28
|
+
sql << "SELECT to_jsonb(e) AS e FROM " << table_sql(graph.edges_table) << " AS e"
|
|
29
|
+
sql << " JOIN " << table_sql(graph.nodes_table) << " AS s ON s.id = e.source_id AND "
|
|
30
|
+
append_node_visible(sql, "s")
|
|
31
|
+
sql << " JOIN " << table_sql(graph.nodes_table) << " AS t ON t.id = e.target_id AND "
|
|
32
|
+
append_node_visible(sql, "t")
|
|
33
|
+
sql << " WHERE e.id = "
|
|
34
|
+
sql.bind(ref.id, "bigint") << " AND "
|
|
35
|
+
append_edge_visible(sql, e)
|
|
36
|
+
row = read(sql).first
|
|
37
|
+
row && graph.build_edge(JSON.parse(row["e"]))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The visible node of +type+ (or a subtype) with the given key, or nil.
|
|
41
|
+
# ctx.find_node(:Person, uid: "PER-12")
|
|
42
|
+
def find_node(type, **key)
|
|
43
|
+
node_type = graph.type.node_type!(type)
|
|
44
|
+
unless node_type.key && key.keys == [node_type.key.property]
|
|
45
|
+
raise ArgumentError, "#{type} is looked up by #{node_type.key&.property.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
match.node(:n, type, **key).first&.fetch(:n)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def insert_node(type, properties: {}, attrs: {}) = writer.insert_node(type, properties:, attrs:)
|
|
52
|
+
|
|
53
|
+
def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
|
|
54
|
+
|
|
55
|
+
def insert_edge(type, from:, to:, properties: {}, attrs: {}) = writer.insert_edge(type, from:, to:, properties:, attrs:)
|
|
56
|
+
|
|
57
|
+
def update_edge(ref, properties: {}, attrs: {}) = writer.update_edge(ref, properties:, attrs:)
|
|
58
|
+
|
|
59
|
+
def delete_edge(ref) = writer.delete_edge(ref)
|
|
60
|
+
|
|
61
|
+
# The trusted writer: caller-chosen keys, upserts and node deletion.
|
|
62
|
+
# Raises NotTrusted unless the application's trusted_writer_allowed
|
|
63
|
+
# callable approves this context. It does not bypass validation, the
|
|
64
|
+
# write policy or endpoint checks.
|
|
65
|
+
def trusted_writer
|
|
66
|
+
allowed = graph.trusted_writer_allowed
|
|
67
|
+
raise NotTrusted, "trusted writer not granted" unless allowed.respond_to?(:call) && allowed.call(self) == true
|
|
68
|
+
|
|
69
|
+
Writer.new(self, trusted: true)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect = "#<Pglpg::Context #{graph.name} viewer=#{viewer.inspect}>"
|
|
73
|
+
|
|
74
|
+
# -- internal ------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def table_sql(name) = graph.connection.quote_table_name(name)
|
|
77
|
+
|
|
78
|
+
# Appends the visibility condition for the node alias +name+.
|
|
79
|
+
def append_node_visible(sql, name)
|
|
80
|
+
sql << "("
|
|
81
|
+
sql.arel(policy_condition(graph.visibility.node(Arel::Table.new(name), viewer)))
|
|
82
|
+
sql << ")"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def append_edge_visible(sql, name)
|
|
86
|
+
sql << "("
|
|
87
|
+
sql.arel(policy_condition(graph.visibility.edge(Arel::Table.new(name), viewer)))
|
|
88
|
+
sql << ")"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Runs a read under the configured statement timeout.
|
|
92
|
+
def read(sql)
|
|
93
|
+
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
|
+
c.transaction(requires_new: true) do
|
|
98
|
+
previous = c.select_value("SELECT current_setting('statement_timeout')")
|
|
99
|
+
c.select_value("SELECT set_config('statement_timeout', '#{Integer(timeout)}', true)")
|
|
100
|
+
rows = c.select_all(sql.to_s, "Pglpg", sql.binds).to_a
|
|
101
|
+
c.select_value("SELECT set_config('statement_timeout', #{c.quote(previous)}, true)")
|
|
102
|
+
rows
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
rescue ActiveRecord::QueryCanceled
|
|
106
|
+
raise QueryTimeout
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def writer = Writer.new(self, trusted: false)
|
|
112
|
+
|
|
113
|
+
# A policy must return an Arel condition; anything else fails closed.
|
|
114
|
+
def policy_condition(node)
|
|
115
|
+
return node if node.is_a?(Arel::Nodes::Node) || node.is_a?(Arel::Nodes::SqlLiteral)
|
|
116
|
+
|
|
117
|
+
raise ConfigurationError, "policy returned #{node.class}, expected an Arel condition"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# A node read from the graph. Values are frozen.
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :ref, :key_label, :labels, :properties, :attrs, :created_at, :updated_at
|
|
7
|
+
|
|
8
|
+
def initialize(ref:, key_label:, labels:, properties:, attrs:, created_at:, updated_at:)
|
|
9
|
+
@ref = ref
|
|
10
|
+
@key_label = key_label
|
|
11
|
+
@labels = labels
|
|
12
|
+
@properties = properties
|
|
13
|
+
@attrs = attrs
|
|
14
|
+
@created_at = created_at
|
|
15
|
+
@updated_at = updated_at
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def id = ref.id
|
|
20
|
+
|
|
21
|
+
def [](name) = properties[name.to_sym]
|
|
22
|
+
|
|
23
|
+
def label?(label) = labels.include?(label.to_sym)
|
|
24
|
+
|
|
25
|
+
def ==(other) = other.is_a?(Node) && other.ref == ref
|
|
26
|
+
|
|
27
|
+
alias eql? ==
|
|
28
|
+
|
|
29
|
+
def hash = ref.hash
|
|
30
|
+
|
|
31
|
+
def inspect = "#<Pglpg::Node #{ref} #{key_label} #{properties.inspect}>"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# An edge read from the graph. Values are frozen.
|
|
35
|
+
class Edge
|
|
36
|
+
attr_reader :ref, :type, :source, :target, :properties, :attrs, :created_at, :updated_at
|
|
37
|
+
|
|
38
|
+
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:)
|
|
39
|
+
@ref = ref
|
|
40
|
+
@type = type
|
|
41
|
+
@source = source
|
|
42
|
+
@target = target
|
|
43
|
+
@properties = properties
|
|
44
|
+
@attrs = attrs
|
|
45
|
+
@created_at = created_at
|
|
46
|
+
@updated_at = updated_at
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def id = ref.id
|
|
51
|
+
|
|
52
|
+
def [](name) = properties[name.to_sym]
|
|
53
|
+
|
|
54
|
+
def ==(other) = other.is_a?(Edge) && other.ref == ref
|
|
55
|
+
|
|
56
|
+
alias eql? ==
|
|
57
|
+
|
|
58
|
+
def hash = ref.hash
|
|
59
|
+
|
|
60
|
+
def inspect = "#<Pglpg::Edge #{ref} #{type} #{source.id}->#{target.id} #{properties.inspect}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/pglpg/errors.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# Base class for every error pglpg raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The graph type declaration is invalid.
|
|
8
|
+
class SchemaError < Error; end
|
|
9
|
+
|
|
10
|
+
# pglpg is used without a required policy or setting.
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
# A Ref belongs to another graph or is the wrong kind of element.
|
|
14
|
+
class InvalidRef < Error; end
|
|
15
|
+
|
|
16
|
+
# Properties or application columns do not match the graph type.
|
|
17
|
+
class ValidationError < Error; end
|
|
18
|
+
|
|
19
|
+
# A write was refused: by the write policy, by visibility, or because the
|
|
20
|
+
# target does not exist. The error deliberately carries no Ref and no row
|
|
21
|
+
# data, so a refusal looks the same whatever the reason.
|
|
22
|
+
class WriteRejected < Error
|
|
23
|
+
def initialize(msg = "write rejected")
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The application did not grant the trusted-writer capability.
|
|
29
|
+
class NotTrusted < Error; end
|
|
30
|
+
|
|
31
|
+
# A query without an explicit limit matched more rows than max_rows allows.
|
|
32
|
+
class TooManyRows < Error; end
|
|
33
|
+
|
|
34
|
+
# A query ran past its statement timeout.
|
|
35
|
+
class QueryTimeout < Error
|
|
36
|
+
def initialize(msg = "query timed out")
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/pglpg/graph.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pglpg
|
|
6
|
+
# A property graph: its graph type, its storage, and the policies that
|
|
7
|
+
# guard it. Every read and write starts from #for.
|
|
8
|
+
#
|
|
9
|
+
# Kg = Pglpg::Graph.define(:kg) do
|
|
10
|
+
# node_column :classification, :integer, null: false
|
|
11
|
+
# node :Person do
|
|
12
|
+
# property :uid, :string, required: true
|
|
13
|
+
# key :uid
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
# Kg.for(viewer: current_viewer).match.node(:p, :Person).to_a
|
|
17
|
+
class Graph
|
|
18
|
+
attr_reader :name, :type, :storage
|
|
19
|
+
|
|
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)
|
|
22
|
+
type = Schema::GraphType.new
|
|
23
|
+
Schema::Builder.new(type).instance_eval(&block) if block
|
|
24
|
+
type.resolve!
|
|
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:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(name, type, nodes_table:, edges_table:, visibility:, write_policy:, trusted_writer_allowed:,
|
|
30
|
+
connection_class:)
|
|
31
|
+
@name = Schema.check_name!(name, "graph")
|
|
32
|
+
@type = type
|
|
33
|
+
@storage = Storage.new(self, nodes_table:, edges_table:)
|
|
34
|
+
@visibility = visibility
|
|
35
|
+
@write_policy = write_policy
|
|
36
|
+
@trusted_writer_allowed = trusted_writer_allowed
|
|
37
|
+
@connection_class = connection_class
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The only entry point for queries and writes. The returned context is
|
|
42
|
+
# immutable and carries the viewer to everything built from it.
|
|
43
|
+
def for(viewer:)
|
|
44
|
+
Context.new(self, viewer)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ref(kind, id) = Ref.new(graph: name, kind:, id:)
|
|
48
|
+
|
|
49
|
+
# Parses "graph:kind:id" as produced by Ref#to_s.
|
|
50
|
+
def parse_ref(string)
|
|
51
|
+
graph, kind, id = string.to_s.split(":", 3)
|
|
52
|
+
raise InvalidRef, "not a ref of graph #{name}" unless graph == name.to_s && %w[node edge].include?(kind)
|
|
53
|
+
|
|
54
|
+
ref(kind, Integer(id, 10))
|
|
55
|
+
rescue ArgumentError, TypeError
|
|
56
|
+
raise InvalidRef, "malformed ref"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raises unless +ref+ is a Ref of this graph and of the given kind.
|
|
60
|
+
def check_ref!(ref, kind)
|
|
61
|
+
raise InvalidRef, "expected a Pglpg::Ref" unless ref.is_a?(Ref)
|
|
62
|
+
raise InvalidRef, "ref belongs to graph #{ref.graph}, not #{name}" unless ref.graph == name
|
|
63
|
+
raise InvalidRef, "expected a #{kind} ref" unless ref.kind == kind
|
|
64
|
+
|
|
65
|
+
ref
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def visibility
|
|
69
|
+
@visibility || Pglpg.configuration.visibility ||
|
|
70
|
+
raise(ConfigurationError, "no visibility policy configured for graph #{name}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_policy
|
|
74
|
+
@write_policy || Pglpg.configuration.write_policy ||
|
|
75
|
+
raise(ConfigurationError, "no write policy configured for graph #{name}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def trusted_writer_allowed = @trusted_writer_allowed || Pglpg.configuration.trusted_writer_allowed
|
|
79
|
+
|
|
80
|
+
def connection_class = @connection_class || Pglpg.configuration.connection_class || ActiveRecord::Base
|
|
81
|
+
|
|
82
|
+
def with_connection(&) = connection_class.with_connection(&)
|
|
83
|
+
|
|
84
|
+
def connection
|
|
85
|
+
connection_class.respond_to?(:lease_connection) ? connection_class.lease_connection : connection_class.connection
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def nodes_table = storage.nodes_table
|
|
89
|
+
|
|
90
|
+
def edges_table = storage.edges_table
|
|
91
|
+
|
|
92
|
+
def inspect = "#<Pglpg::Graph #{name}>"
|
|
93
|
+
|
|
94
|
+
# Builds a Node from the to_jsonb() of a nodes row.
|
|
95
|
+
def build_node(row)
|
|
96
|
+
node_type = type.node_types[row["key_label"].to_sym]
|
|
97
|
+
Node.new(
|
|
98
|
+
ref: ref(:node, row["id"]), key_label: row["key_label"].to_sym,
|
|
99
|
+
labels: row["labels"].map(&:to_sym).freeze,
|
|
100
|
+
properties: node_type ? node_type.decode_properties(row["properties"]) : row["properties"].transform_keys(&:to_sym).freeze,
|
|
101
|
+
attrs: decode_attrs(type.node_columns, row),
|
|
102
|
+
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"])
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Builds an Edge from the to_jsonb() of an edges row.
|
|
107
|
+
def build_edge(row)
|
|
108
|
+
edge_type = type.edge_types[row["type"].to_sym]
|
|
109
|
+
Edge.new(
|
|
110
|
+
ref: ref(:edge, row["id"]), type: row["type"].to_sym,
|
|
111
|
+
source: ref(:node, row["source_id"]), target: ref(:node, row["target_id"]),
|
|
112
|
+
properties: edge_type ? edge_type.decode_properties(row["properties"]) : row["properties"].transform_keys(&:to_sym).freeze,
|
|
113
|
+
attrs: decode_attrs(type.edge_columns, row),
|
|
114
|
+
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"])
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def decode_attrs(columns, row)
|
|
121
|
+
columns.each_value.to_h do |c|
|
|
122
|
+
value = row[c.name.to_s]
|
|
123
|
+
cast = begin
|
|
124
|
+
ActiveModel::Type.lookup(c.type).cast(value)
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
[c.name, cast]
|
|
129
|
+
end.freeze
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|