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 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
@@ -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
@@ -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