pglpg 0.1.0 → 0.3.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 +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61a05d0f91dc037aefaaf82fa99143eae9a3c2742dd2308adcffa0d364b2f6fc
|
|
4
|
+
data.tar.gz: 1286500cadead4a337382e5bba4a5ec60bc861bcec88bfb04c07856aadaa4cf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c76e6ed407cbb7ef56cc9e79999cad98c91d67e7f46b08b2aba548c197cd52409b6d70ae83b029e032c9c3dcda1594a3825241727a0da45c91a553c456bead57
|
|
7
|
+
data.tar.gz: 5c4c89d4af4a6f57988a734cc0d154b9b9c96a8dea5a15c51b7ab3802078f27d5468e7f42c6e98a6698befca2cb58c9ce7e5c9cc44e0482470d157951100a804
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,254 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- Temporal-capable reads require `graph.for(viewer:, as_of: Date)` or
|
|
8
|
+
`as_of: :any`; omitted/nil mode permits only node-only and statically
|
|
9
|
+
timeless-only reads. Generic edge Ref lookups in graphs with temporal types
|
|
10
|
+
require a mode even for missing edges. Date contexts refuse all data writes
|
|
11
|
+
with `ReadOnlyContext`, including model no-op saves and trusted-writer access.
|
|
12
|
+
- Temporal inserts and every temporal upsert require explicit `valid_from:`
|
|
13
|
+
presence (nil is unbounded); omitted update bounds preserve stored values.
|
|
14
|
+
On upsert, an omitted upper bound is nil on insert and preserved on conflict.
|
|
15
|
+
- Enabling temporal types on existing populated tables requires
|
|
16
|
+
`storage.enable_temporal!(types:, backfill: nil)` in an explicit migration.
|
|
17
|
+
Reserved `valid_from`/`valid_to` names must first be removed from application
|
|
18
|
+
declarations. Incompatible legacy columns require an application migration;
|
|
19
|
+
timestamp values are never silently cast to dates. The helper locks the
|
|
20
|
+
table and atomically installs metadata/checks plus an optional backfill.
|
|
21
|
+
- Complete-key lookups validate literal component values: nil, arrays and
|
|
22
|
+
query conditions are refused before SQL, including on existing single/scoped
|
|
23
|
+
keys. Use `match` for structured property filters.
|
|
24
|
+
- Adopting composite/edge keys or endpoint/property uniqueness requires
|
|
25
|
+
auditing and backfilling all required components, resolving duplicates and
|
|
26
|
+
running `storage.sync_constraints!` in the same application transaction.
|
|
27
|
+
New caller-chosen key forms require the trusted writer; unique components
|
|
28
|
+
cannot be changed and keyed edge upserts cannot rewire endpoints.
|
|
29
|
+
- Opting a type into `open: true` rejects duplicate String/Symbol aliases for
|
|
30
|
+
declared fields as well as extras. Closed normalization is unchanged.
|
|
31
|
+
Openness inherits from any open parent; local false does not close it.
|
|
32
|
+
Closing/promoting declarations needs explicit data audit/backfill.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Explicit `open: true` node and edge properties (ADR-0015), with openness
|
|
37
|
+
inherited from any open node parent. Closed remains the default. Declared
|
|
38
|
+
property/key checks, labels, endpoints, visibility and temporal rules remain.
|
|
39
|
+
- Extra properties accept strict JSON-native scalars and flat homogeneous
|
|
40
|
+
scalar arrays; nil means absent/removal. Invalid names, duplicate aliases,
|
|
41
|
+
metadata/application-column collisions and unsupported values are refused
|
|
42
|
+
before the data statement. Open-type alias rejection includes declared
|
|
43
|
+
fields; closed normalization is unchanged. Extra decoded JSON values,
|
|
44
|
+
including historical map keys, are deeply frozen.
|
|
45
|
+
- Model `extra_properties` remains read-only and declared saves preserve
|
|
46
|
+
extras. Query/filter/order/pluck still require declared properties. Openness
|
|
47
|
+
adds no DDL; closing/promoting types requires explicit application auditing
|
|
48
|
+
and backfill. Optional `pglpg_open_types` shared leak fixtures cover extras.
|
|
49
|
+
|
|
50
|
+
- Composite node keys and edge property keys (ADR-0014): `key :a, :b` on
|
|
51
|
+
required string/integer/boolean/enum properties, with inherited node-key
|
|
52
|
+
domains and exact edge-type domains. General tuples and single non-string
|
|
53
|
+
keys require trusted insertion; legacy single-string generation and scoped
|
|
54
|
+
node occupancy grants retain their behavior. All components are immutable.
|
|
55
|
+
Unsupported edge scope declarations raise `SchemaError` explicitly.
|
|
56
|
+
- `Context#find_edge(type, **key)` looks up a full edge property key through
|
|
57
|
+
current visibility on the edge and both endpoints, with the context's date.
|
|
58
|
+
Complete-key node/edge lookups validate literal values before SQL.
|
|
59
|
+
- `unique: [:p, :q]` enforces a required endpoint/property tuple per edge type.
|
|
60
|
+
Trusted keyed edge upsert accepts `key:` and requires unchanged endpoints;
|
|
61
|
+
without a property key, upsert uses endpoint/property uniqueness. Constraints
|
|
62
|
+
are cumulative and span all dates. Uniqueness properties cannot be updated.
|
|
63
|
+
- Composite/non-string model keys are required attributes, created through
|
|
64
|
+
`save(writer:)`; generated keys remain unassignable. Composite key readers
|
|
65
|
+
return frozen maps; legacy node scalar/scoped key readers stay scalar.
|
|
66
|
+
Adoption requires application backfill/deduplication and transactional
|
|
67
|
+
`sync_constraints!`; no automatic data migration is performed.
|
|
68
|
+
|
|
69
|
+
- Context inspection shows an explicitly selected Date or `:any` read mode.
|
|
70
|
+
- Opt-in `temporal: true` edge types (ADR-0013): exact Date metadata,
|
|
71
|
+
half-open `[valid_from, valid_to)` intervals, nil unbounded, finite dates
|
|
72
|
+
0001-01-01 through 9999-12-31. DateTime, Time and String are rejected.
|
|
73
|
+
- Context-wide date eligibility on direct edge reads, every pattern and
|
|
74
|
+
reachability hop, models, lazy associations and eager batches, combined with
|
|
75
|
+
current visibility before counts, pagination, limits and completeness.
|
|
76
|
+
Timeless edges remain eligible; `:any` ignores only validity. This is valid
|
|
77
|
+
time on the stored graph, not historical values or historical permissions.
|
|
78
|
+
- `TemporalModeRequired` and `TemporalMigrationRequired` errors. Generated
|
|
79
|
+
CHECK digests track explicit temporal enablement; changed/absent or
|
|
80
|
+
unvalidated checks on populated temporal types require acknowledgement.
|
|
81
|
+
Omitted backfill preserves bounds, and disabling types retains columns.
|
|
82
|
+
- Edge values expose `temporal?`, `valid_from`, `valid_to`; temporal models
|
|
83
|
+
have strict metadata attributes, explicit nil assignment tracking and bound
|
|
84
|
+
pluck support. Lifetime pair uniqueness remains unchanged: gaps require
|
|
85
|
+
separate non-pair-unique episode Refs; overlaps are allowed.
|
|
86
|
+
- Optional `pglpg_temporal` shared leak-fixture mode, and `bench/temporal.rb`
|
|
87
|
+
for same-fixture reference comparisons and temporal index experiments.
|
|
88
|
+
|
|
89
|
+
### Security
|
|
90
|
+
|
|
91
|
+
- Schema constraint failures use a generic ValidationError without a database
|
|
92
|
+
cause or stored-row detail. Temporal upsert argument validation is independent
|
|
93
|
+
of visible, hidden or absent pair occupancy.
|
|
94
|
+
|
|
95
|
+
## 0.2.0 (2026-10-30)
|
|
96
|
+
|
|
97
|
+
### Breaking
|
|
98
|
+
|
|
99
|
+
- Symmetric edge types use `both`; `out` and `in` reject them, and `both`
|
|
100
|
+
rejects directed types. After adding a symmetric type, run
|
|
101
|
+
`Kg.storage.sync_constraints!` to install its canonical-endpoint constraint.
|
|
102
|
+
- Adopting scoped keys requires a required string scope property, both scope
|
|
103
|
+
and key in lookups and trusted upserts, and a transactional backfill with
|
|
104
|
+
`sync_constraints!` on the same connection. Interactive caller-chosen scoped
|
|
105
|
+
keys require an explicit `key_scope_allowed` occupancy grant. Existing
|
|
106
|
+
unscoped key declarations keep their contract.
|
|
107
|
+
|
|
108
|
+
### Security
|
|
109
|
+
|
|
110
|
+
- Database constraint failures on refused writes no longer expose the original
|
|
111
|
+
database exception through `Exception#cause`, including unscoped writes.
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- Scoped keys (ADR-0012): `key :uid, scope: :namespace` enforces a required
|
|
116
|
+
string namespace/key pair across the declaring type and subtypes. Interactive
|
|
117
|
+
caller-chosen keys require an explicit `key_scope_allowed` grant to learn
|
|
118
|
+
occupancy within that graph/domain/scope, including hidden collisions. The
|
|
119
|
+
grant gives no row access and has no SQL-time revocation guarantee. Trusted
|
|
120
|
+
upserts and `find_node` require both parts; scope and key remain immutable.
|
|
121
|
+
Existing schemas require a transactional backfill and constraint migration.
|
|
122
|
+
|
|
123
|
+
- 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`.
|
|
124
|
+
|
|
125
|
+
- Model layer, reads (ADR-0010; `require "pglpg/model"`):
|
|
126
|
+
- `Pglpg::Model::Node` and `Pglpg::Model::Edge` are ActiveModel classes
|
|
127
|
+
bound to a graph and a schema type with `graph Kg, type: :Person`. Their
|
|
128
|
+
attributes are the type's properties and the graph's application
|
|
129
|
+
columns. Assignment is strict: no form coercion.
|
|
130
|
+
- Declaring a model rejects a second model for the same type (a class of
|
|
131
|
+
the same name replaces it, for code reloading), a property named like an
|
|
132
|
+
application column or a model method, and a model extending another
|
|
133
|
+
model off the schema's inheritance.
|
|
134
|
+
- `Model.in(ctx)` returns a `Pglpg::Model::Relation` with `where`, `order`,
|
|
135
|
+
`limit`, `offset`, `count`, `exists?`, `first` and `find_by`. It compiles
|
|
136
|
+
to a `Pglpg::Query`, so visibility, ordering, `max_rows` and timeouts
|
|
137
|
+
apply as there. Edge relations return each symmetric edge once.
|
|
138
|
+
- `Relation#find(ref)` takes a `Ref` or its String form and raises
|
|
139
|
+
`Pglpg::Model::RecordNotFound` for a missing, hidden or wrong-type
|
|
140
|
+
element alike.
|
|
141
|
+
- A node loads as the model registered for its concrete type, else as the
|
|
142
|
+
queried model; `schema_type` names the stored type and
|
|
143
|
+
`extra_properties` holds properties the model doesn't declare.
|
|
144
|
+
- The shared leak suite observes models, relations and `find`.
|
|
145
|
+
- Model layer, writes (ADR-0010):
|
|
146
|
+
- `Relation#new` / `#build`, `#create` and `#create!` build records bound
|
|
147
|
+
to the relation's context. Edge records take `source:` and `target:` (a
|
|
148
|
+
node Ref or model); they can't change once stored.
|
|
149
|
+
- `save` / `save!` validate, then insert a new record or update only its
|
|
150
|
+
changed attributes through the context's guarded write API. `save`
|
|
151
|
+
returns false on validation errors; `save!` raises
|
|
152
|
+
`Pglpg::Model::RecordInvalid`. Refusals, timeouts and database errors
|
|
153
|
+
raise, and changes survive any failure. Without changes, no statement
|
|
154
|
+
runs.
|
|
155
|
+
- Schema validations: property values, required properties and required
|
|
156
|
+
columns. Keys are generated: an assigned key is an error on create, a
|
|
157
|
+
changed key on update. Abstract types can't be created.
|
|
158
|
+
- `save(writer:)` and `destroy(writer:)` accept only the record's own
|
|
159
|
+
context's trusted writer (`ArgumentError` otherwise), for trusted-only
|
|
160
|
+
writes such as pair-unique edges; it never permits assigning a key.
|
|
161
|
+
- `destroy` deletes an edge, or a node through the trusted writer only;
|
|
162
|
+
`destroyed?`.
|
|
163
|
+
- `reload` replaces attributes, timestamps and the dirty baseline, and
|
|
164
|
+
raises `RecordNotFound` without changing the record when the element
|
|
165
|
+
is missing or hidden.
|
|
166
|
+
- After a create, generated keys and timestamps are unknown until
|
|
167
|
+
`reload`. A caller's rollback is not tracked.
|
|
168
|
+
- Model layer, associations (ADR-0010 §5):
|
|
169
|
+
- `has_many name, :out | :in | :both, edge:, label: nil, model: nil` on a
|
|
170
|
+
node model defines a method returning a `Pglpg::Model::AssociationRelation`.
|
|
171
|
+
`:both` is for symmetric types, `:out` / `:in` for directed ones. The
|
|
172
|
+
target label defaults to the edge type's other endpoint and the model to
|
|
173
|
+
the one registered for it. Declaring checks the direction, that the
|
|
174
|
+
owner can be an endpoint, and name clashes; associations are inherited.
|
|
175
|
+
- A target query returns each visible target once: duplicates are removed
|
|
176
|
+
before `order`, `limit`, `offset`, `count` and `exists?`. `rel_where`
|
|
177
|
+
filters on edge properties ("at least one matching visible edge");
|
|
178
|
+
`rel_order` raises there.
|
|
179
|
+
- `with_rel` returns a `Pglpg::Model::BindingRelation`: one
|
|
180
|
+
`[target, edge]` pair per matching edge, counted and paginated as pairs,
|
|
181
|
+
with `rel_order`. It must come before `order`, `limit` or `offset`.
|
|
182
|
+
`each_with_rel` enumerates it. Edges load as the registered edge model.
|
|
183
|
+
- Associations need a saved owner, start from it (so its visibility
|
|
184
|
+
applies) and can't build records yet.
|
|
185
|
+
- The shared leak suite observes every association of every visible owner:
|
|
186
|
+
targets, counts, `exists?`, `first`, pagination, `rel_where` and pairs.
|
|
187
|
+
- Model layer, `pluck` (ADR-0010 §5):
|
|
188
|
+
- `Relation#pluck(*names)` returns attribute values (or `:id`, `:ref`) per
|
|
189
|
+
element, in the relation's order: a value for one name, an Array for
|
|
190
|
+
several. Elements are chosen first and projected afterwards, so two
|
|
191
|
+
targets with equal values give two values.
|
|
192
|
+
- `BindingRelation#pluck(*names, rel: [])` gives one entry per pair:
|
|
193
|
+
the target's names, then the edge's `rel` names.
|
|
194
|
+
- Unknown names raise `ArgumentError` before any query runs; `max_rows`
|
|
195
|
+
applies as for `to_a`.
|
|
196
|
+
- The shared leak suite also observes `pluck` on relations, associations
|
|
197
|
+
and pairs.
|
|
198
|
+
- `Context#reachable(from:, via:, max_depth:, max_nodes:, direction: :out, deadline: nil)`
|
|
199
|
+
returns a `Pglpg::Reachability`: visible nodes with their minimum depth,
|
|
200
|
+
ordered by depth then id, plus `complete` and `stopped_at_depth`.
|
|
201
|
+
- Runs as one recursive CTE that computes one BFS level per step (ADR-0009),
|
|
202
|
+
so it sees one snapshot at any isolation level.
|
|
203
|
+
- The start node is never returned; a hidden start and a missing one give
|
|
204
|
+
the same empty, complete result.
|
|
205
|
+
- `direction:` follows the `match` rule from ADR-0008, and a `via` list may
|
|
206
|
+
not mix directed and symmetric types.
|
|
207
|
+
- `max_nodes` may not exceed `max_rows`. `deadline` (seconds) can only
|
|
208
|
+
shorten the configured `statement_timeout`.
|
|
209
|
+
- The shared leak suite observes `reachable` from every visible node.
|
|
210
|
+
- `Context#read` takes a `timeout:` in milliseconds (internal), and every read
|
|
211
|
+
now runs in a savepoint, also with `statement_timeout: nil`. A timeout set
|
|
212
|
+
by the session itself no longer leaves the caller's transaction aborted.
|
|
213
|
+
- The shared leak suite keeps its `reachable` budgets within `max_rows`.
|
|
214
|
+
- The benchmark is rebuilt for CI (task 36367): seeded, versioned 1m,
|
|
215
|
+
hub-heavy and 10m fixtures with a manifest. Its cells cover viewers L2 and
|
|
216
|
+
L4, with starts each viewer can see, and include `reachable`. It gates
|
|
217
|
+
per-cell bounds in `bench/bounds.yml`, and compares buffers and latency
|
|
218
|
+
with a baseline revision. Buffers come from JSON plans and fail closed, and
|
|
219
|
+
a self-test proves that the gate fails. `bench/run.rb` is replaced by
|
|
220
|
+
`bench/measure.rb` and `bench/gate.rb`. A new `bench.yml` workflow runs the
|
|
221
|
+
full fixtures. The CI benchmark step no longer hides a failure behind
|
|
222
|
+
`tee`.
|
|
223
|
+
- Symmetric edge types (ADR-0008): `edge name, from:, to:, symmetric: true`,
|
|
224
|
+
with `allow_self_loops: true` to permit self-loops (off by default).
|
|
225
|
+
- Each edge is stored once with `source_id < target_id` (`<=` with self-loops),
|
|
226
|
+
enforced by a generated `CHECK`; `sync_constraints!` adds it. Writes accept
|
|
227
|
+
the endpoints in either order, and `WriteRequest#from` / `#to` carry the
|
|
228
|
+
canonical order. `unique: :pair` covers the unordered pair.
|
|
229
|
+
- One endpoint carries `from` and the other `to`, in either order.
|
|
230
|
+
- `Query#both` follows a symmetric edge from either end (GQL `~[ ]~`). With
|
|
231
|
+
no endpoint fixed, each edge binds once per orientation (once for a
|
|
232
|
+
self-loop); use `return(:edge).distinct` to list edges once.
|
|
233
|
+
- **Breaking:** `out` and `in` raise `ArgumentError` for symmetric types, and
|
|
234
|
+
`both` raises it for directed types. `symmetric:` no longer raises
|
|
235
|
+
`SchemaError`.
|
|
236
|
+
- `Edge#symmetric?` and `Edge#other(ref)`.
|
|
237
|
+
- The shared leak suite adds symmetric cases: hidden bridges and hidden
|
|
238
|
+
nodes stored at either end of an edge.
|
|
239
|
+
- The benchmark adds symmetric 1- and 2-hop shapes and checks the buffers of
|
|
240
|
+
the symmetric hub query.
|
|
241
|
+
|
|
242
|
+
### Fixed
|
|
243
|
+
|
|
244
|
+
- Storage DDL is atomic: `sync_constraints!` and the entire `create!` operation
|
|
245
|
+
use a transaction on their supplied connection, or a savepoint in an existing
|
|
246
|
+
ActiveRecord transaction. A failed call restores its DDL and leaves a healthy
|
|
247
|
+
caller transaction usable. Outside a transaction, each call commits its own
|
|
248
|
+
transaction. Application backfills still need an explicit outer transaction
|
|
249
|
+
on the same connection to be rolled back with a failed schema transition.
|
|
250
|
+
|
|
251
|
+
|
|
3
252
|
## 0.1.0 (2026-10-28)
|
|
4
253
|
|
|
5
254
|
First version, following the accepted 0.1 baseline (knowen.md bb topic 145).
|
data/README.md
CHANGED
|
@@ -8,10 +8,65 @@ 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.3.0 release candidate; not yet published.** The latest published
|
|
12
|
+
version is 0.2.0. This candidate includes temporal edges, composite/edge keys
|
|
13
|
+
and explicit open properties. The design and its decisions live in the
|
|
12
14
|
[pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
|
|
13
15
|
ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
|
|
14
16
|
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
After 0.3.0 publication:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "pglpg", "~> 0.3.0"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Before 1.0, minor releases may break compatibility. Read the
|
|
26
|
+
[0.3.0 upgrade notes](CHANGELOG.md) and the migration steps below before
|
|
27
|
+
upgrading from 0.2. Applications still on 0.1 must also apply the 0.2 notes.
|
|
28
|
+
|
|
29
|
+
## Upgrade from 0.2
|
|
30
|
+
|
|
31
|
+
- Choose `as_of: Date` for dated reads or `as_of: :any` for all dates.
|
|
32
|
+
Omitted mode permits node-only/timeless-only reads; generic edge Ref lookup
|
|
33
|
+
needs a mode whenever the graph declares a temporal edge type. Dated
|
|
34
|
+
contexts are read-only, including model saves and trusted-writer access.
|
|
35
|
+
- Temporal inserts and every upsert require explicit `valid_from:` (nil means
|
|
36
|
+
unbounded). Update omissions preserve bounds; an upsert's omitted upper
|
|
37
|
+
bound is nil on insert and preserved on conflict.
|
|
38
|
+
- Before enabling temporal traffic on existing tables, finish an explicit
|
|
39
|
+
`enable_temporal!` migration. Remove conflicting application declarations,
|
|
40
|
+
audit legacy timestamps and `valid_until` boundaries, and convert them in
|
|
41
|
+
application migrations without silent truncation. Keep reads/writes disabled
|
|
42
|
+
during a deploy-then-migrate window. Retained temporal columns make every
|
|
43
|
+
constraint sync take an edge-table `ACCESS EXCLUSIVE` lock until outer commit;
|
|
44
|
+
schedule short migration transactions.
|
|
45
|
+
- Adopt new key/uniqueness declarations with an explicit same-connection
|
|
46
|
+
transaction: backfill required components, resolve duplicates, then call
|
|
47
|
+
`sync_constraints!`. Composite/non-string keys and endpoint-unique edges
|
|
48
|
+
require trusted creation. Unique properties are immutable; keyed edge upsert
|
|
49
|
+
cannot rewire endpoints. Legacy single-string generation/scoped grants and
|
|
50
|
+
lifetime pair uniqueness retain their rules. Complete-key lookups take
|
|
51
|
+
literal values; use `match` for condition/array filters.
|
|
52
|
+
- `open: true` is optional and changes no DDL. It rejects duplicate aliases across all
|
|
53
|
+
submitted properties, reserves structural/validity names even on other
|
|
54
|
+
element kinds/timeless graphs, and inherits from any open parent. Audit data
|
|
55
|
+
before closing a type or promoting extras to declarations. Queries and model
|
|
56
|
+
attributes still require declared properties; low-level writes and model
|
|
57
|
+
reload manage extras.
|
|
58
|
+
|
|
59
|
+
Existing timeless, closed schemas with unchanged keys need no migration solely
|
|
60
|
+
for the version bump. Rerun your real-policy leak suite and label-drift checks
|
|
61
|
+
when adopting this minor release.
|
|
62
|
+
|
|
63
|
+
This candidate implements a documented standards subset: key components are
|
|
64
|
+
string/integer/boolean/enum, open extras are scalars/flat homogeneous lists,
|
|
65
|
+
and temporal validity is one date interval with current authorization. It does
|
|
66
|
+
not add alternate/overlap keys, open labels, dynamic query/model attributes,
|
|
67
|
+
chained model associations or a GQL parser. Production-like acceptance remains
|
|
68
|
+
separate pre-1.0 work; ordinary checks do not establish managed-plan capacity.
|
|
69
|
+
|
|
15
70
|
## Define a graph
|
|
16
71
|
|
|
17
72
|
```ruby
|
|
@@ -36,12 +91,17 @@ Kg = Pglpg::Graph.define(:kg) do
|
|
|
36
91
|
property :role, :string
|
|
37
92
|
end
|
|
38
93
|
edge :KNOWS, from: :Person, to: :Person
|
|
94
|
+
edge :FRIEND, from: :Person, to: :Person, symmetric: true # undirected
|
|
39
95
|
end
|
|
40
96
|
```
|
|
41
97
|
|
|
98
|
+
A `symmetric: true` edge type is undirected: each edge is stored once, smaller
|
|
99
|
+
node id first, and writes accept the endpoints in either order. Self-loops are
|
|
100
|
+
refused unless the type sets `allow_self_loops: true`.
|
|
101
|
+
|
|
42
102
|
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.
|
|
103
|
+
(`enum` takes `values:`, `list` takes `of:`). Types are closed by default: undeclared
|
|
104
|
+
properties are rejected unless the type opts into open properties. A nil value means the property is absent.
|
|
45
105
|
|
|
46
106
|
In a migration:
|
|
47
107
|
|
|
@@ -118,6 +178,10 @@ rows.first[:q] # => #<Pglpg::Node ...>
|
|
|
118
178
|
g.find(ref) # visible node or edge, else nil
|
|
119
179
|
g.find_node(:Person, uid: "PER-12")
|
|
120
180
|
query.count; query.exists?; query.first; query.distinct; query.explain(analyze: true)
|
|
181
|
+
|
|
182
|
+
# Symmetric edge types use both (out and in refuse them). Each edge binds once
|
|
183
|
+
# per orientation, so list edges once with return(:f).distinct.
|
|
184
|
+
g.match.node(:p, ref: ann).both(:FRIEND, :q, :Person, edge: :f)
|
|
121
185
|
```
|
|
122
186
|
|
|
123
187
|
Conditions are structured only: a value (equality), an array (one of), nil
|
|
@@ -125,6 +189,208 @@ Conditions are structured only: a value (equality), an array (one of), nil
|
|
|
125
189
|
raise `Pglpg::TooManyRows` beyond `max_rows` (default 1000); reads run under
|
|
126
190
|
`statement_timeout` (default 5 s) and raise `Pglpg::QueryTimeout`.
|
|
127
191
|
|
|
192
|
+
## Reachability
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
r = g.reachable(from: ann, via: %i[KNOWS], max_depth: 3, max_nodes: 500, deadline: 2.0)
|
|
196
|
+
r.nodes # => [[ref, 1], [ref, 1], [ref, 2], …] by depth, then id
|
|
197
|
+
r.complete? # false when more visible nodes are reachable within max_depth
|
|
198
|
+
r.stopped_at_depth # depth of the last returned node when incomplete, else nil
|
|
199
|
+
|
|
200
|
+
g.reachable(from: ann, via: %i[KNOWS], direction: :in, max_depth: 2, max_nodes: 100)
|
|
201
|
+
g.reachable(from: ann, via: %i[FRIEND], direction: :both, max_depth: 2, max_nodes: 100)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Each visible node comes once, at its minimum depth; the start node never does.
|
|
205
|
+
Hidden nodes and edges are never traversed, and a hidden or missing start gives
|
|
206
|
+
an empty, complete result. `via` lists edge types that may be followed at any
|
|
207
|
+
hop. `direction:` follows the same rule as `match`: `:both` takes only
|
|
208
|
+
symmetric types, `:out` (the default) and `:in` only directed ones.
|
|
209
|
+
`max_nodes` may not exceed `max_rows`.
|
|
210
|
+
|
|
211
|
+
The traversal is one SQL statement (ADR-0009), so it sees one snapshot at any
|
|
212
|
+
isolation level. `deadline` is in seconds and can only shorten the configured
|
|
213
|
+
`statement_timeout`; with no timeout configured it applies alone. Running out
|
|
214
|
+
of time raises `Pglpg::QueryTimeout` and leaves your transaction usable.
|
|
215
|
+
|
|
216
|
+
## Models and eager loading
|
|
217
|
+
|
|
218
|
+
`require "pglpg/model"` adds optional ActiveModel records bound to a graph and
|
|
219
|
+
schema type. Context-bound relations support reads, guarded saves, associations,
|
|
220
|
+
`pluck` and visibility-aware batched eager loading with `with_associations`.
|
|
221
|
+
See the [API contract](https://knowen.md/nodes/36329) for examples, cache lifetime
|
|
222
|
+
and row limits.
|
|
223
|
+
|
|
224
|
+
## Scoped keys
|
|
225
|
+
|
|
226
|
+
A type can declare `key :uid, scope: :namespace` when both properties are
|
|
227
|
+
required strings. Uniqueness covers the scope/key pair across the type and its
|
|
228
|
+
subtypes. `find_node` and trusted upserts require both values. An interactive
|
|
229
|
+
caller-chosen key needs an explicit `key_scope_allowed` grant: it permits
|
|
230
|
+
learning occupancy in that scope, including hidden collisions, without granting
|
|
231
|
+
row access. Scope and key are immutable. Existing data needs a transactional
|
|
232
|
+
backfill and constraint migration; see the
|
|
233
|
+
[API contract](https://knowen.md/nodes/36329).
|
|
234
|
+
|
|
235
|
+
## Composite and edge keys (0.3.0 candidate)
|
|
236
|
+
|
|
237
|
+
Keys can use a tuple of required string, integer, boolean or enum properties:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
node :Document do
|
|
241
|
+
property :catalog, :string, required: true
|
|
242
|
+
property :number, :integer, required: true
|
|
243
|
+
key :catalog, :number
|
|
244
|
+
end
|
|
245
|
+
edge :CITES, from: :Document, to: :Document do
|
|
246
|
+
property :source, :string, required: true
|
|
247
|
+
property :external_id, :string, required: true
|
|
248
|
+
key :source, :external_id
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
General tuples and single non-string keys require a trusted writer on insert.
|
|
253
|
+
Single string keys retain automatic generation; existing scoped node keys
|
|
254
|
+
retain their explicit occupancy grant. Edge keys are unique within the edge
|
|
255
|
+
type, independently of endpoints. A keyed edge upsert requires the full key
|
|
256
|
+
and the original endpoints; it cannot rewire a relationship.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
w = ctx.trusted_writer
|
|
260
|
+
ref = w.upsert_node(:Document, key: { catalog: "main", number: 7 })
|
|
261
|
+
ctx.find_node(:Document, catalog: "main", number: 7)
|
|
262
|
+
w.upsert_edge(:CITES, from: ref, to: other,
|
|
263
|
+
key: { source: "archive", external_id: "citation-1" })
|
|
264
|
+
ctx.find_edge(:CITES, source: "archive", external_id: "citation-1")
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`unique: [:role]` on an edge type adds the required role to its endpoint-pair
|
|
268
|
+
uniqueness tuple. It allows different roles between the same endpoints and
|
|
269
|
+
requires trusted insertion/upsert. A declared `key` and `unique:` can coexist;
|
|
270
|
+
keyed upsert uses the key, and both constraints still apply. All key and
|
|
271
|
+
uniqueness properties are immutable, and uniqueness spans all validity dates.
|
|
272
|
+
Symmetric types use the canonical unordered endpoint pair.
|
|
273
|
+
|
|
274
|
+
Composite model attributes are required; create with `save(writer: w)` using
|
|
275
|
+
the record's own context's writer. Generated model keys remain unassignable.
|
|
276
|
+
Existing data needs an explicit transactional backfill/deduplication followed
|
|
277
|
+
by `storage.sync_constraints!` on the same connection. No automatic migration
|
|
278
|
+
runs when a declaration is loaded. Other property types, optional uniqueness
|
|
279
|
+
and multiple alternate keys are outside this key API.
|
|
280
|
+
|
|
281
|
+
## Open properties (0.3.0 candidate)
|
|
282
|
+
|
|
283
|
+
Types stay closed by default. Opt in to extra properties explicitly:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
node :Person, open: true do
|
|
287
|
+
property :uid, :string, required: true
|
|
288
|
+
key :uid
|
|
289
|
+
end
|
|
290
|
+
edge :MENTIONS, from: :Person, to: :Person, open: true
|
|
291
|
+
|
|
292
|
+
ref = ctx.insert_node(:Person, properties: { source_note: "archive", scores: [1, 2] })
|
|
293
|
+
ctx.find(ref)[:scores] # => [1, 2], frozen
|
|
294
|
+
ctx.update_node(ref, properties: { source_note: nil }) # removes the extra
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
An open parent makes its descendants open, including with multiple parents;
|
|
298
|
+
`open: false` on a child does not close an open ancestor. Declared properties,
|
|
299
|
+
keys, endpoints and generated labels retain their rules.
|
|
300
|
+
|
|
301
|
+
Extras accept strings, integers, finite floats, booleans and flat homogeneous
|
|
302
|
+
scalar arrays (including empty arrays). Nil means absent/removal. Maps, nested
|
|
303
|
+
or mixed arrays, nil array members, Symbols, dates/times, decimals and custom
|
|
304
|
+
objects need explicit application conversion or a declared property type.
|
|
305
|
+
Extra names cannot collide with structural row names, regardless of element
|
|
306
|
+
kind (so even a node extra cannot be named `type`, `source_id` or `target_id`).
|
|
307
|
+
Validity names `valid_from`/`valid_to` are reserved even in wholly timeless
|
|
308
|
+
graphs. Application-column names of that element kind are also reserved.
|
|
309
|
+
Invalid names are refused. On open types, duplicate String/Symbol aliases are
|
|
310
|
+
rejected for every submitted property, including declared properties. Closed
|
|
311
|
+
types retain their existing last-alias normalization.
|
|
312
|
+
|
|
313
|
+
Queries, ordering, model attributes and `pluck` still require declarations.
|
|
314
|
+
Models expose extras through their read-only `extra_properties`; saving declared
|
|
315
|
+
attributes preserves them. Write extras through `Context`/`Writer` and reload
|
|
316
|
+
the model to refresh its map. No dynamic query casts or model setters are added.
|
|
317
|
+
|
|
318
|
+
Extras carry application data and gain no automatic tenant/ACL or visibility
|
|
319
|
+
semantics. Existing row/write policies still apply, and write policies can
|
|
320
|
+
inspect submitted extras. Keep security-relevant fields in explicit declarations
|
|
321
|
+
or application columns, with an explicit authorization policy.
|
|
322
|
+
|
|
323
|
+
Openness alone adds no DDL. Closing a type preserves existing extra data on
|
|
324
|
+
reads and declared-only updates; it refuses resubmitted unknown properties.
|
|
325
|
+
Audit/backfill before closing types or promoting extras to typed declarations.
|
|
326
|
+
Shape validation is at the library write boundary; raw SQL does not gain an
|
|
327
|
+
open/closed constraint. The existing required-property and key checks remain.
|
|
328
|
+
|
|
329
|
+
## Temporal edges (0.3.0 candidate)
|
|
330
|
+
|
|
331
|
+
Opt in per edge type and use one date across the whole query:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
edge :MEMBER_OF, from: :Person, to: :Organization, temporal: true
|
|
335
|
+
|
|
336
|
+
w = Kg.for(viewer: current_viewer)
|
|
337
|
+
w.insert_edge(:MEMBER_OF, from: ann, to: org,
|
|
338
|
+
valid_from: Date.new(2020, 1, 1), valid_to: Date.new(2021, 1, 1),
|
|
339
|
+
attrs: { classification: 1 })
|
|
340
|
+
|
|
341
|
+
at = Kg.for(viewer: current_viewer, as_of: Date.new(2020, 6, 1))
|
|
342
|
+
at.match.node(:p, ref: ann).out(:MEMBER_OF, :o).to_a
|
|
343
|
+
all_dates = Kg.for(viewer: current_viewer, as_of: :any)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Intervals include the start and exclude the end. Bounds are exact Ruby Dates
|
|
347
|
+
in 0001–9999 or nil for unbounded; unknown/uncertain dates need separate
|
|
348
|
+
application metadata. Required `valid_from:` presence on insert/upsert makes
|
|
349
|
+
all-time intent explicit (`valid_from: nil`). Updates preserve omitted bounds;
|
|
350
|
+
upserts preserve an omitted `valid_to` on conflict but use nil on insert.
|
|
351
|
+
|
|
352
|
+
Date contexts are read-only, including model saves. Omitting `as_of` permits
|
|
353
|
+
node-only and timeless-only reads; temporal reads raise `TemporalModeRequired`.
|
|
354
|
+
Generic edge `find(ref)` requires a mode whenever the schema has a temporal
|
|
355
|
+
type. `:any` remains writable and always retains current visibility. Dated
|
|
356
|
+
reachability, models and preloads use the same date on every hop. This does not
|
|
357
|
+
reconstruct old values, node existence or past permissions.
|
|
358
|
+
|
|
359
|
+
`unique: :pair` still spans all dates. Leave/rejoin episodes with a gap need
|
|
360
|
+
separate Refs on a non-pair-unique type; overlaps are allowed.
|
|
361
|
+
|
|
362
|
+
Run the temporal migration before routing dated reads or temporal writes to the
|
|
363
|
+
updated graph declaration. Loading `temporal: true` does not migrate its tables.
|
|
364
|
+
During a deploy-then-migrate window, keep temporal edge operations disabled
|
|
365
|
+
until the migration commits. Storage helpers report `TemporalMigrationRequired`;
|
|
366
|
+
ordinary reads/writes do not inspect the catalog, so unmigrated operations may
|
|
367
|
+
raise `ActiveRecord::StatementInvalid` with `PG::UndefinedColumn` instead.
|
|
368
|
+
An explicit `:any` read can still work without date columns and does not prove
|
|
369
|
+
that the migration is complete.
|
|
370
|
+
|
|
371
|
+
For existing tables, remove conflicting application declarations and migrate
|
|
372
|
+
incompatible legacy timestamp columns explicitly before enabling temporal types:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
Kg.storage.enable_temporal!(types: [:MEMBER_OF], backfill: ->(connection) {
|
|
376
|
+
# Explicit application backfill, on this connection, after checks are enabled.
|
|
377
|
+
# Without a callback, existing bounds are preserved; newly added NULL bounds
|
|
378
|
+
# deliberately mean all-time. Never invent dates or silently discard times.
|
|
379
|
+
})
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The helper locks the edge table, adds missing nullable date columns and runs
|
|
383
|
+
constraint sync/backfill atomically. An outer transaction keeps its work pending
|
|
384
|
+
until commit. Callback errors or invalid intervals roll everything back; the
|
|
385
|
+
callback must not commit or switch connections. Callback idempotency is yours.
|
|
386
|
+
Bare `sync_constraints!` refuses missing columns or unacknowledged populated
|
|
387
|
+
temporal types. Once temporal columns exist, every synchronization takes an
|
|
388
|
+
`ACCESS EXCLUSIVE` edge-table lock, including after the last temporal type is
|
|
389
|
+
disabled and for unrelated schema changes. This blocks edge reads and writes
|
|
390
|
+
until the caller's outer commit; keep the migration transaction short and
|
|
391
|
+
schedule it accordingly. Physical columns remain after temporal types are disabled.
|
|
392
|
+
See [ADR-0013](https://knowen.md/nodes/36532) for the complete contract.
|
|
393
|
+
|
|
128
394
|
## Leak specs for your policy
|
|
129
395
|
|
|
130
396
|
```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
|
|