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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64d4bfb95eeacd8297e72c8dc02b95bcda6354e5c3c157655483c13d78e9b9a8
4
- data.tar.gz: c75cbedcd42f7fe07265f8aecfea1e78adc5e44f3651587b069955ea6c47f254
3
+ metadata.gz: 0ed878f97c7dcf3ffb630de85d798896dcbb6b0729e176ec410eb6383bd4659e
4
+ data.tar.gz: '055047480e44e5ff29ccaddce6d3ed730397dcffa243541c42e4c1e5b3c607ac'
5
5
  SHA512:
6
- metadata.gz: 1b0d31c4236eac2f7b3cb22b65d7650c1a01d3687ea5505b44e64b540e54f9d6e880344e2bbc1f90a6bb1512dcfdaad5c06ea0dc58ea621f768e82c50926e513
7
- data.tar.gz: a8bcb6da10e7779df9409d7dc5923a0f172177d4d2acb8f695a347adf89019bc1d04b4a5187b8c85560b7043b171bfc0bc464c1256189c4ae43db59d5c4f0a5d
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.1, pre-release.** The design and its decisions live in the
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
@@ -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 == [node_type.key.property]
45
- raise ArgumentError, "#{type} is looked up by #{node_type.key&.property.inspect}"
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 timeout.
92
- def read(sql)
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}->#{target.id} #{properties.inspect}>"
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