pglpg 0.2.0 → 0.4.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 +101 -0
- data/README.md +227 -8
- data/lib/pglpg/context.rb +55 -8
- data/lib/pglpg/element.rb +8 -1
- data/lib/pglpg/errors.rb +19 -0
- data/lib/pglpg/graph.rb +5 -2
- data/lib/pglpg/match_parser.rb +308 -0
- data/lib/pglpg/model/base.rb +11 -1
- data/lib/pglpg/model/edge.rb +67 -2
- data/lib/pglpg/model/node.rb +13 -8
- data/lib/pglpg/model/relation.rb +9 -2
- data/lib/pglpg/query.rb +21 -7
- data/lib/pglpg/reachability.rb +2 -0
- data/lib/pglpg/schema/edge_type.rb +28 -6
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +21 -7
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -31
- data/lib/pglpg/storage.rb +121 -8
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +43 -15
- data/lib/pglpg/testing.rb +10 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +89 -40
- data/lib/pglpg.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dcdce167b8e343909f06e710fdb65a1833a5cfa3649c85a475050c830a463a0d
|
|
4
|
+
data.tar.gz: f7725ba5bd6255af98a77875b766ac0e7d3a180f4af7ced79b50446998fbda6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e536165dfa69606c33d7c15c4dd77c68878a3be01b6f8b7b097e4effd3597d26a6b65e2dd1c87d2ce5247c588980bebb779af6e674d4e29d84f3efcf05187b22
|
|
7
|
+
data.tar.gz: 7c6fbe3e465c7134ae05ec5601e61f941462687f60f1dd91242641b1ab22446e44d0f9836c3db4cb2ca6c330f0136ead5cd8c23c305eac300ff9d15374aa645a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,106 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `Context#parse_match(text, params: {})` builds a lazy query from a read-only
|
|
8
|
+
fixed-length GQL MATCH subset. It uses the Ruby query builder, named value
|
|
9
|
+
binds, and the caller’s visibility, date mode and execution limits. Malformed
|
|
10
|
+
query text and invalid parameters report located `Pglpg::ParseError` diagnostics.
|
|
11
|
+
|
|
12
|
+
## 0.3.0 (2026-11-03)
|
|
13
|
+
|
|
14
|
+
### Breaking
|
|
15
|
+
|
|
16
|
+
- Temporal-capable reads require `graph.for(viewer:, as_of: Date)` or
|
|
17
|
+
`as_of: :any`; omitted/nil mode permits only node-only and statically
|
|
18
|
+
timeless-only reads. Generic edge Ref lookups in graphs with temporal types
|
|
19
|
+
require a mode even for missing edges. Date contexts refuse all data writes
|
|
20
|
+
with `ReadOnlyContext`, including model no-op saves and trusted-writer access.
|
|
21
|
+
- Temporal inserts and every temporal upsert require explicit `valid_from:`
|
|
22
|
+
presence (nil is unbounded); omitted update bounds preserve stored values.
|
|
23
|
+
On upsert, an omitted upper bound is nil on insert and preserved on conflict.
|
|
24
|
+
- Enabling temporal types on existing populated tables requires
|
|
25
|
+
`storage.enable_temporal!(types:, backfill: nil)` in an explicit migration.
|
|
26
|
+
Reserved `valid_from`/`valid_to` names must first be removed from application
|
|
27
|
+
declarations. Incompatible legacy columns require an application migration;
|
|
28
|
+
timestamp values are never silently cast to dates. The helper locks the
|
|
29
|
+
table and atomically installs metadata/checks plus an optional backfill.
|
|
30
|
+
- Complete-key lookups validate literal component values: nil, arrays and
|
|
31
|
+
query conditions are refused before SQL, including on existing single/scoped
|
|
32
|
+
keys. Use `match` for structured property filters.
|
|
33
|
+
- Adopting composite/edge keys or endpoint/property uniqueness requires
|
|
34
|
+
auditing and backfilling all required components, resolving duplicates and
|
|
35
|
+
running `storage.sync_constraints!` in the same application transaction.
|
|
36
|
+
New caller-chosen key forms require the trusted writer; unique components
|
|
37
|
+
cannot be changed and keyed edge upserts cannot rewire endpoints.
|
|
38
|
+
- Opting a type into `open: true` rejects duplicate String/Symbol aliases for
|
|
39
|
+
declared fields as well as extras. Closed normalization is unchanged.
|
|
40
|
+
Openness inherits from any open parent; local false does not close it.
|
|
41
|
+
Closing/promoting declarations needs explicit data audit/backfill.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Explicit `open: true` node and edge properties (ADR-0015), with openness
|
|
46
|
+
inherited from any open node parent. Closed remains the default. Declared
|
|
47
|
+
property/key checks, labels, endpoints, visibility and temporal rules remain.
|
|
48
|
+
- Extra properties accept strict JSON-native scalars and flat homogeneous
|
|
49
|
+
scalar arrays; nil means absent/removal. Invalid names, duplicate aliases,
|
|
50
|
+
metadata/application-column collisions and unsupported values are refused
|
|
51
|
+
before the data statement. Open-type alias rejection includes declared
|
|
52
|
+
fields; closed normalization is unchanged. Extra decoded JSON values,
|
|
53
|
+
including historical map keys, are deeply frozen.
|
|
54
|
+
- Model `extra_properties` remains read-only and declared saves preserve
|
|
55
|
+
extras. Query/filter/order/pluck still require declared properties. Openness
|
|
56
|
+
adds no DDL; closing/promoting types requires explicit application auditing
|
|
57
|
+
and backfill. Optional `pglpg_open_types` shared leak fixtures cover extras.
|
|
58
|
+
|
|
59
|
+
- Composite node keys and edge property keys (ADR-0014): `key :a, :b` on
|
|
60
|
+
required string/integer/boolean/enum properties, with inherited node-key
|
|
61
|
+
domains and exact edge-type domains. General tuples and single non-string
|
|
62
|
+
keys require trusted insertion; legacy single-string generation and scoped
|
|
63
|
+
node occupancy grants retain their behavior. All components are immutable.
|
|
64
|
+
Unsupported edge scope declarations raise `SchemaError` explicitly.
|
|
65
|
+
- `Context#find_edge(type, **key)` looks up a full edge property key through
|
|
66
|
+
current visibility on the edge and both endpoints, with the context's date.
|
|
67
|
+
Complete-key node/edge lookups validate literal values before SQL.
|
|
68
|
+
- `unique: [:p, :q]` enforces a required endpoint/property tuple per edge type.
|
|
69
|
+
Trusted keyed edge upsert accepts `key:` and requires unchanged endpoints;
|
|
70
|
+
without a property key, upsert uses endpoint/property uniqueness. Constraints
|
|
71
|
+
are cumulative and span all dates. Uniqueness properties cannot be updated.
|
|
72
|
+
- Composite/non-string model keys are required attributes, created through
|
|
73
|
+
`save(writer:)`; generated keys remain unassignable. Composite key readers
|
|
74
|
+
return frozen maps; legacy node scalar/scoped key readers stay scalar.
|
|
75
|
+
Adoption requires application backfill/deduplication and transactional
|
|
76
|
+
`sync_constraints!`; no automatic data migration is performed.
|
|
77
|
+
|
|
78
|
+
- Context inspection shows an explicitly selected Date or `:any` read mode.
|
|
79
|
+
- Opt-in `temporal: true` edge types (ADR-0013): exact Date metadata,
|
|
80
|
+
half-open `[valid_from, valid_to)` intervals, nil unbounded, finite dates
|
|
81
|
+
0001-01-01 through 9999-12-31. DateTime, Time and String are rejected.
|
|
82
|
+
- Context-wide date eligibility on direct edge reads, every pattern and
|
|
83
|
+
reachability hop, models, lazy associations and eager batches, combined with
|
|
84
|
+
current visibility before counts, pagination, limits and completeness.
|
|
85
|
+
Timeless edges remain eligible; `:any` ignores only validity. This is valid
|
|
86
|
+
time on the stored graph, not historical values or historical permissions.
|
|
87
|
+
- `TemporalModeRequired` and `TemporalMigrationRequired` errors. Generated
|
|
88
|
+
CHECK digests track explicit temporal enablement; changed/absent or
|
|
89
|
+
unvalidated checks on populated temporal types require acknowledgement.
|
|
90
|
+
Omitted backfill preserves bounds, and disabling types retains columns.
|
|
91
|
+
- Edge values expose `temporal?`, `valid_from`, `valid_to`; temporal models
|
|
92
|
+
have strict metadata attributes, explicit nil assignment tracking and bound
|
|
93
|
+
pluck support. Lifetime pair uniqueness remains unchanged: gaps require
|
|
94
|
+
separate non-pair-unique episode Refs; overlaps are allowed.
|
|
95
|
+
- Optional `pglpg_temporal` shared leak-fixture mode, and `bench/temporal.rb`
|
|
96
|
+
for same-fixture reference comparisons and temporal index experiments.
|
|
97
|
+
|
|
98
|
+
### Security
|
|
99
|
+
|
|
100
|
+
- Schema constraint failures use a generic ValidationError without a database
|
|
101
|
+
cause or stored-row detail. Temporal upsert argument validation is independent
|
|
102
|
+
of visible, hidden or absent pair occupancy.
|
|
103
|
+
|
|
3
104
|
## 0.2.0 (2026-10-30)
|
|
4
105
|
|
|
5
106
|
### Breaking
|
data/README.md
CHANGED
|
@@ -8,21 +8,81 @@ 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.
|
|
12
|
-
|
|
11
|
+
Status: **0.4.0 release candidate**, with a read-only text MATCH parser.
|
|
12
|
+
The latest published version is 0.3.0. The design and its decisions live in the
|
|
13
13
|
[pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
|
|
14
14
|
ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
|
-
After publication:
|
|
19
|
-
|
|
20
18
|
```ruby
|
|
21
|
-
gem "pglpg", "~> 0.
|
|
19
|
+
gem "pglpg", "~> 0.3.0"
|
|
22
20
|
```
|
|
23
21
|
|
|
24
22
|
Before 1.0, minor releases may break compatibility. Read the
|
|
25
|
-
[0.
|
|
23
|
+
[0.3.0 upgrade notes](CHANGELOG.md) and the migration steps below before
|
|
24
|
+
upgrading from 0.2. Applications still on 0.1 must also apply the 0.2 notes.
|
|
25
|
+
|
|
26
|
+
After 0.4.0 is published, use `gem "pglpg", "~> 0.4.0"`.
|
|
27
|
+
|
|
28
|
+
## Upgrade from 0.3
|
|
29
|
+
|
|
30
|
+
The parser is additive and requires no schema migration. Existing Ruby queries
|
|
31
|
+
keep their behavior. Use text MATCH with named value parameters on an existing
|
|
32
|
+
context:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
query = context.parse_match(
|
|
36
|
+
"MATCH (p:Person) WHERE p.name = $name RETURN p LIMIT 20",
|
|
37
|
+
params: { name: "Ann" }
|
|
38
|
+
)
|
|
39
|
+
query.to_a
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Text supports fixed-length patterns and a subset of read clauses. It uses the
|
|
43
|
+
context's visibility policy, date mode, and execution limits. See the
|
|
44
|
+
[API contract](https://knowen.md/nodes/36329) for the grammar and error codes.
|
|
45
|
+
|
|
46
|
+
## Upgrade from 0.2
|
|
47
|
+
|
|
48
|
+
- Choose `as_of: Date` for dated reads or `as_of: :any` for all dates.
|
|
49
|
+
Omitted mode permits node-only/timeless-only reads; generic edge Ref lookup
|
|
50
|
+
needs a mode whenever the graph declares a temporal edge type. Dated
|
|
51
|
+
contexts are read-only, including model saves and trusted-writer access.
|
|
52
|
+
- Temporal inserts and every upsert require explicit `valid_from:` (nil means
|
|
53
|
+
unbounded). Update omissions preserve bounds; an upsert's omitted upper
|
|
54
|
+
bound is nil on insert and preserved on conflict.
|
|
55
|
+
- Before enabling temporal traffic on existing tables, finish an explicit
|
|
56
|
+
`enable_temporal!` migration. Remove conflicting application declarations,
|
|
57
|
+
audit legacy timestamps and `valid_until` boundaries, and convert them in
|
|
58
|
+
application migrations without silent truncation. Keep reads/writes disabled
|
|
59
|
+
during a deploy-then-migrate window. Retained temporal columns make every
|
|
60
|
+
constraint sync take an edge-table `ACCESS EXCLUSIVE` lock until outer commit;
|
|
61
|
+
schedule short migration transactions.
|
|
62
|
+
- Adopt new key/uniqueness declarations with an explicit same-connection
|
|
63
|
+
transaction: backfill required components, resolve duplicates, then call
|
|
64
|
+
`sync_constraints!`. Composite/non-string keys and endpoint-unique edges
|
|
65
|
+
require trusted creation. Unique properties are immutable; keyed edge upsert
|
|
66
|
+
cannot rewire endpoints. Legacy single-string generation/scoped grants and
|
|
67
|
+
lifetime pair uniqueness retain their rules. Complete-key lookups take
|
|
68
|
+
literal values; use `match` for condition/array filters.
|
|
69
|
+
- `open: true` is optional and changes no DDL. It rejects duplicate aliases across all
|
|
70
|
+
submitted properties, reserves structural/validity names even on other
|
|
71
|
+
element kinds/timeless graphs, and inherits from any open parent. Audit data
|
|
72
|
+
before closing a type or promoting extras to declarations. Queries and model
|
|
73
|
+
attributes still require declared properties; low-level writes and model
|
|
74
|
+
reload manage extras.
|
|
75
|
+
|
|
76
|
+
Existing timeless, closed schemas with unchanged keys need no migration solely
|
|
77
|
+
for the version bump. Rerun your real-policy leak suite and label-drift checks
|
|
78
|
+
when adopting this minor release.
|
|
79
|
+
|
|
80
|
+
This release implements a documented standards subset: key components are
|
|
81
|
+
string/integer/boolean/enum, open extras are scalars/flat homogeneous lists,
|
|
82
|
+
and temporal validity is one date interval with current authorization. It does
|
|
83
|
+
not add alternate/overlap keys, open labels, dynamic query/model attributes,
|
|
84
|
+
chained model associations or a GQL parser. Production-like acceptance remains
|
|
85
|
+
separate pre-1.0 work; ordinary checks do not establish managed-plan capacity.
|
|
26
86
|
|
|
27
87
|
## Define a graph
|
|
28
88
|
|
|
@@ -57,8 +117,8 @@ node id first, and writes accept the endpoints in either order. Self-loops are
|
|
|
57
117
|
refused unless the type sets `allow_self_loops: true`.
|
|
58
118
|
|
|
59
119
|
Property types: `string integer float decimal boolean date datetime enum list`
|
|
60
|
-
(`enum` takes `values:`, `list` takes `of:`). Types are closed: undeclared
|
|
61
|
-
properties are rejected. A nil value means the property is absent.
|
|
120
|
+
(`enum` takes `values:`, `list` takes `of:`). Types are closed by default: undeclared
|
|
121
|
+
properties are rejected unless the type opts into open properties. A nil value means the property is absent.
|
|
62
122
|
|
|
63
123
|
In a migration:
|
|
64
124
|
|
|
@@ -189,6 +249,165 @@ row access. Scope and key are immutable. Existing data needs a transactional
|
|
|
189
249
|
backfill and constraint migration; see the
|
|
190
250
|
[API contract](https://knowen.md/nodes/36329).
|
|
191
251
|
|
|
252
|
+
## Composite and edge keys (0.3.0)
|
|
253
|
+
|
|
254
|
+
Keys can use a tuple of required string, integer, boolean or enum properties:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
node :Document do
|
|
258
|
+
property :catalog, :string, required: true
|
|
259
|
+
property :number, :integer, required: true
|
|
260
|
+
key :catalog, :number
|
|
261
|
+
end
|
|
262
|
+
edge :CITES, from: :Document, to: :Document do
|
|
263
|
+
property :source, :string, required: true
|
|
264
|
+
property :external_id, :string, required: true
|
|
265
|
+
key :source, :external_id
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
General tuples and single non-string keys require a trusted writer on insert.
|
|
270
|
+
Single string keys retain automatic generation; existing scoped node keys
|
|
271
|
+
retain their explicit occupancy grant. Edge keys are unique within the edge
|
|
272
|
+
type, independently of endpoints. A keyed edge upsert requires the full key
|
|
273
|
+
and the original endpoints; it cannot rewire a relationship.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
w = ctx.trusted_writer
|
|
277
|
+
ref = w.upsert_node(:Document, key: { catalog: "main", number: 7 })
|
|
278
|
+
ctx.find_node(:Document, catalog: "main", number: 7)
|
|
279
|
+
w.upsert_edge(:CITES, from: ref, to: other,
|
|
280
|
+
key: { source: "archive", external_id: "citation-1" })
|
|
281
|
+
ctx.find_edge(:CITES, source: "archive", external_id: "citation-1")
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`unique: [:role]` on an edge type adds the required role to its endpoint-pair
|
|
285
|
+
uniqueness tuple. It allows different roles between the same endpoints and
|
|
286
|
+
requires trusted insertion/upsert. A declared `key` and `unique:` can coexist;
|
|
287
|
+
keyed upsert uses the key, and both constraints still apply. All key and
|
|
288
|
+
uniqueness properties are immutable, and uniqueness spans all validity dates.
|
|
289
|
+
Symmetric types use the canonical unordered endpoint pair.
|
|
290
|
+
|
|
291
|
+
Composite model attributes are required; create with `save(writer: w)` using
|
|
292
|
+
the record's own context's writer. Generated model keys remain unassignable.
|
|
293
|
+
Existing data needs an explicit transactional backfill/deduplication followed
|
|
294
|
+
by `storage.sync_constraints!` on the same connection. No automatic migration
|
|
295
|
+
runs when a declaration is loaded. Other property types, optional uniqueness
|
|
296
|
+
and multiple alternate keys are outside this key API.
|
|
297
|
+
|
|
298
|
+
## Open properties (0.3.0)
|
|
299
|
+
|
|
300
|
+
Types stay closed by default. Opt in to extra properties explicitly:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
node :Person, open: true do
|
|
304
|
+
property :uid, :string, required: true
|
|
305
|
+
key :uid
|
|
306
|
+
end
|
|
307
|
+
edge :MENTIONS, from: :Person, to: :Person, open: true
|
|
308
|
+
|
|
309
|
+
ref = ctx.insert_node(:Person, properties: { source_note: "archive", scores: [1, 2] })
|
|
310
|
+
ctx.find(ref)[:scores] # => [1, 2], frozen
|
|
311
|
+
ctx.update_node(ref, properties: { source_note: nil }) # removes the extra
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
An open parent makes its descendants open, including with multiple parents;
|
|
315
|
+
`open: false` on a child does not close an open ancestor. Declared properties,
|
|
316
|
+
keys, endpoints and generated labels retain their rules.
|
|
317
|
+
|
|
318
|
+
Extras accept strings, integers, finite floats, booleans and flat homogeneous
|
|
319
|
+
scalar arrays (including empty arrays). Nil means absent/removal. Maps, nested
|
|
320
|
+
or mixed arrays, nil array members, Symbols, dates/times, decimals and custom
|
|
321
|
+
objects need explicit application conversion or a declared property type.
|
|
322
|
+
Extra names cannot collide with structural row names, regardless of element
|
|
323
|
+
kind (so even a node extra cannot be named `type`, `source_id` or `target_id`).
|
|
324
|
+
Validity names `valid_from`/`valid_to` are reserved even in wholly timeless
|
|
325
|
+
graphs. Application-column names of that element kind are also reserved.
|
|
326
|
+
Invalid names are refused. On open types, duplicate String/Symbol aliases are
|
|
327
|
+
rejected for every submitted property, including declared properties. Closed
|
|
328
|
+
types retain their existing last-alias normalization.
|
|
329
|
+
|
|
330
|
+
Queries, ordering, model attributes and `pluck` still require declarations.
|
|
331
|
+
Models expose extras through their read-only `extra_properties`; saving declared
|
|
332
|
+
attributes preserves them. Write extras through `Context`/`Writer` and reload
|
|
333
|
+
the model to refresh its map. No dynamic query casts or model setters are added.
|
|
334
|
+
|
|
335
|
+
Extras carry application data and gain no automatic tenant/ACL or visibility
|
|
336
|
+
semantics. Existing row/write policies still apply, and write policies can
|
|
337
|
+
inspect submitted extras. Keep security-relevant fields in explicit declarations
|
|
338
|
+
or application columns, with an explicit authorization policy.
|
|
339
|
+
|
|
340
|
+
Openness alone adds no DDL. Closing a type preserves existing extra data on
|
|
341
|
+
reads and declared-only updates; it refuses resubmitted unknown properties.
|
|
342
|
+
Audit/backfill before closing types or promoting extras to typed declarations.
|
|
343
|
+
Shape validation is at the library write boundary; raw SQL does not gain an
|
|
344
|
+
open/closed constraint. The existing required-property and key checks remain.
|
|
345
|
+
|
|
346
|
+
## Temporal edges (0.3.0)
|
|
347
|
+
|
|
348
|
+
Opt in per edge type and use one date across the whole query:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
edge :MEMBER_OF, from: :Person, to: :Organization, temporal: true
|
|
352
|
+
|
|
353
|
+
w = Kg.for(viewer: current_viewer)
|
|
354
|
+
w.insert_edge(:MEMBER_OF, from: ann, to: org,
|
|
355
|
+
valid_from: Date.new(2020, 1, 1), valid_to: Date.new(2021, 1, 1),
|
|
356
|
+
attrs: { classification: 1 })
|
|
357
|
+
|
|
358
|
+
at = Kg.for(viewer: current_viewer, as_of: Date.new(2020, 6, 1))
|
|
359
|
+
at.match.node(:p, ref: ann).out(:MEMBER_OF, :o).to_a
|
|
360
|
+
all_dates = Kg.for(viewer: current_viewer, as_of: :any)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Intervals include the start and exclude the end. Bounds are exact Ruby Dates
|
|
364
|
+
in 0001–9999 or nil for unbounded; unknown/uncertain dates need separate
|
|
365
|
+
application metadata. Required `valid_from:` presence on insert/upsert makes
|
|
366
|
+
all-time intent explicit (`valid_from: nil`). Updates preserve omitted bounds;
|
|
367
|
+
upserts preserve an omitted `valid_to` on conflict but use nil on insert.
|
|
368
|
+
|
|
369
|
+
Date contexts are read-only, including model saves. Omitting `as_of` permits
|
|
370
|
+
node-only and timeless-only reads; temporal reads raise `TemporalModeRequired`.
|
|
371
|
+
Generic edge `find(ref)` requires a mode whenever the schema has a temporal
|
|
372
|
+
type. `:any` remains writable and always retains current visibility. Dated
|
|
373
|
+
reachability, models and preloads use the same date on every hop. This does not
|
|
374
|
+
reconstruct old values, node existence or past permissions.
|
|
375
|
+
|
|
376
|
+
`unique: :pair` still spans all dates. Leave/rejoin episodes with a gap need
|
|
377
|
+
separate Refs on a non-pair-unique type; overlaps are allowed.
|
|
378
|
+
|
|
379
|
+
Run the temporal migration before routing dated reads or temporal writes to the
|
|
380
|
+
updated graph declaration. Loading `temporal: true` does not migrate its tables.
|
|
381
|
+
During a deploy-then-migrate window, keep temporal edge operations disabled
|
|
382
|
+
until the migration commits. Storage helpers report `TemporalMigrationRequired`;
|
|
383
|
+
ordinary reads/writes do not inspect the catalog, so unmigrated operations may
|
|
384
|
+
raise `ActiveRecord::StatementInvalid` with `PG::UndefinedColumn` instead.
|
|
385
|
+
An explicit `:any` read can still work without date columns and does not prove
|
|
386
|
+
that the migration is complete.
|
|
387
|
+
|
|
388
|
+
For existing tables, remove conflicting application declarations and migrate
|
|
389
|
+
incompatible legacy timestamp columns explicitly before enabling temporal types:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
Kg.storage.enable_temporal!(types: [:MEMBER_OF], backfill: ->(connection) {
|
|
393
|
+
# Explicit application backfill, on this connection, after checks are enabled.
|
|
394
|
+
# Without a callback, existing bounds are preserved; newly added NULL bounds
|
|
395
|
+
# deliberately mean all-time. Never invent dates or silently discard times.
|
|
396
|
+
})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The helper locks the edge table, adds missing nullable date columns and runs
|
|
400
|
+
constraint sync/backfill atomically. An outer transaction keeps its work pending
|
|
401
|
+
until commit. Callback errors or invalid intervals roll everything back; the
|
|
402
|
+
callback must not commit or switch connections. Callback idempotency is yours.
|
|
403
|
+
Bare `sync_constraints!` refuses missing columns or unacknowledged populated
|
|
404
|
+
temporal types. Once temporal columns exist, every synchronization takes an
|
|
405
|
+
`ACCESS EXCLUSIVE` edge-table lock, including after the last temporal type is
|
|
406
|
+
disabled and for unrelated schema changes. This blocks edge reads and writes
|
|
407
|
+
until the caller's outer commit; keep the migration transaction short and
|
|
408
|
+
schedule it accordingly. Physical columns remain after temporal types are disabled.
|
|
409
|
+
See [ADR-0013](https://knowen.md/nodes/36532) for the complete contract.
|
|
410
|
+
|
|
192
411
|
## Leak specs for your policy
|
|
193
412
|
|
|
194
413
|
```ruby
|
data/lib/pglpg/context.rb
CHANGED
|
@@ -4,17 +4,20 @@ module Pglpg
|
|
|
4
4
|
# A graph seen by one viewer. Created by Graph#for; immutable. Every query
|
|
5
5
|
# and write built from it applies the graph's policies for this viewer.
|
|
6
6
|
class Context
|
|
7
|
-
attr_reader :graph, :viewer
|
|
7
|
+
attr_reader :graph, :viewer, :as_of
|
|
8
8
|
|
|
9
|
-
def initialize(graph, viewer)
|
|
9
|
+
def initialize(graph, viewer, as_of: nil)
|
|
10
10
|
@graph = graph
|
|
11
11
|
@viewer = viewer
|
|
12
|
+
@as_of = as_of.equal?(:any) ? :any : Temporal.date(as_of, error: ArgumentError)
|
|
12
13
|
freeze
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Starts a fixed-length pattern query.
|
|
16
17
|
def match = Query.new(self)
|
|
17
18
|
|
|
19
|
+
def parse_match(text, params: {}) = MatchParser.new(self, text, params).parse
|
|
20
|
+
|
|
18
21
|
# The visible node or edge behind +ref+, or nil. A hidden element and a
|
|
19
22
|
# missing one look the same.
|
|
20
23
|
def find(ref)
|
|
@@ -23,6 +26,7 @@ module Pglpg
|
|
|
23
26
|
graph.check_ref!(ref, ref.kind)
|
|
24
27
|
return match.node(:n, ref:).first&.fetch(:n) if ref.node?
|
|
25
28
|
|
|
29
|
+
temporal_mode!(graph.type.edge_types.values)
|
|
26
30
|
sql = Sql.new(graph.connection)
|
|
27
31
|
e = "e"
|
|
28
32
|
sql << "SELECT to_jsonb(e) AS e FROM " << table_sql(graph.edges_table) << " AS e"
|
|
@@ -33,6 +37,7 @@ module Pglpg
|
|
|
33
37
|
sql << " WHERE e.id = "
|
|
34
38
|
sql.bind(ref.id, "bigint") << " AND "
|
|
35
39
|
append_edge_visible(sql, e)
|
|
40
|
+
append_temporal(sql, e, graph.type.edge_types.values)
|
|
36
41
|
row = read(sql).first
|
|
37
42
|
row && graph.build_edge(JSON.parse(row["e"]))
|
|
38
43
|
end
|
|
@@ -42,13 +47,25 @@ module Pglpg
|
|
|
42
47
|
def find_node(type, **key)
|
|
43
48
|
key = key.transform_keys(&:to_sym)
|
|
44
49
|
node_type = graph.type.node_type!(type)
|
|
45
|
-
|
|
46
|
-
raise ArgumentError, "#{type} is looked up by #{node_type.key&.properties.inspect}"
|
|
47
|
-
end
|
|
50
|
+
raise ArgumentError, "#{type} has no key" unless node_type.key
|
|
48
51
|
|
|
52
|
+
node_type.key.validate_values!(node_type, key)
|
|
49
53
|
match.node(:n, type, **key).first&.fetch(:n)
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
# Lookup by an exact edge property key. Compiler guards apply to the
|
|
57
|
+
# edge, both endpoints and the context's selected date.
|
|
58
|
+
def find_edge(type, **key)
|
|
59
|
+
edge_type = graph.type.edge_type!(type)
|
|
60
|
+
raise ArgumentError, "#{type} has no property key" unless edge_type.key
|
|
61
|
+
|
|
62
|
+
key = key.transform_keys(&:to_sym)
|
|
63
|
+
edge_type.key.validate_values!(edge_type, key)
|
|
64
|
+
query = match.node(:s)
|
|
65
|
+
query = query.public_send(edge_type.symmetric? ? :both : :out, type, :t, edge: :e)
|
|
66
|
+
query.where(e: key).return(:e).first&.fetch(:e)
|
|
67
|
+
end
|
|
68
|
+
|
|
52
69
|
# Visible nodes reachable from +from+ within +max_depth+ hops over the
|
|
53
70
|
# edge types in +via+, each with its minimum depth (a Reachability).
|
|
54
71
|
# Hidden nodes and edges are never traversed. The traversal is one
|
|
@@ -63,9 +80,9 @@ module Pglpg
|
|
|
63
80
|
|
|
64
81
|
def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
|
|
65
82
|
|
|
66
|
-
def insert_edge(type, from:, to:, properties: {}, attrs: {}) = writer.insert_edge(type, from:, to:, properties:, attrs
|
|
83
|
+
def insert_edge(type, from:, to:, properties: {}, attrs: {}, **validity) = writer.insert_edge(type, from:, to:, properties:, attrs:, **validity)
|
|
67
84
|
|
|
68
|
-
def update_edge(ref, properties: {}, attrs: {}) = writer.update_edge(ref, properties:, attrs
|
|
85
|
+
def update_edge(ref, properties: {}, attrs: {}, **validity) = writer.update_edge(ref, properties:, attrs:, **validity)
|
|
69
86
|
|
|
70
87
|
def delete_edge(ref) = writer.delete_edge(ref)
|
|
71
88
|
|
|
@@ -74,13 +91,43 @@ module Pglpg
|
|
|
74
91
|
# callable approves this context. It does not bypass validation, the
|
|
75
92
|
# write policy or endpoint checks.
|
|
76
93
|
def trusted_writer
|
|
94
|
+
writable!
|
|
77
95
|
allowed = graph.trusted_writer_allowed
|
|
78
96
|
raise NotTrusted, "trusted writer not granted" unless allowed.respond_to?(:call) && allowed.call(self) == true
|
|
79
97
|
|
|
80
98
|
Writer.new(self, trusted: true)
|
|
81
99
|
end
|
|
82
100
|
|
|
83
|
-
def inspect
|
|
101
|
+
def inspect
|
|
102
|
+
value = as_of.instance_of?(Date) ? as_of.iso8601 : as_of.inspect
|
|
103
|
+
mode = " as_of=#{value}" unless as_of.nil?
|
|
104
|
+
"#<Pglpg::Context #{graph.name} viewer=#{viewer.inspect}#{mode}>"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def writable!
|
|
108
|
+
raise ReadOnlyContext, "dated contexts are read-only" if as_of.instance_of?(Date)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def temporal_mode!(types)
|
|
112
|
+
if as_of.nil? && types.any?(&:temporal?)
|
|
113
|
+
raise TemporalModeRequired, "temporal reads require as_of: Date or :any"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Appended alongside visibility, before every join/expansion/terminal.
|
|
118
|
+
def append_temporal(sql, name, types)
|
|
119
|
+
temporal_mode!(types)
|
|
120
|
+
temporal = types.select(&:temporal?)
|
|
121
|
+
return unless as_of.instance_of?(Date) && temporal.any?
|
|
122
|
+
|
|
123
|
+
sql << " AND ("
|
|
124
|
+
if temporal.size != types.size
|
|
125
|
+
sql << name << ".type <> ALL("
|
|
126
|
+
sql.bind_text_array(temporal.map(&:name)) << ") OR "
|
|
127
|
+
end
|
|
128
|
+
sql << "daterange(" << name << ".valid_from, " << name << ".valid_to, '[)') @> "
|
|
129
|
+
sql.bind(as_of.iso8601, "date") << ")"
|
|
130
|
+
end
|
|
84
131
|
|
|
85
132
|
# -- internal ------------------------------------------------------------
|
|
86
133
|
|
data/lib/pglpg/element.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Pglpg
|
|
|
37
37
|
class Edge
|
|
38
38
|
attr_reader :ref, :type, :source, :target, :properties, :attrs, :created_at, :updated_at
|
|
39
39
|
|
|
40
|
-
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:, symmetric: false)
|
|
40
|
+
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:, symmetric: false, temporal: false, valid_from: nil, valid_to: nil)
|
|
41
41
|
@ref = ref
|
|
42
42
|
@type = type
|
|
43
43
|
@source = source
|
|
@@ -46,6 +46,9 @@ module Pglpg
|
|
|
46
46
|
@attrs = attrs
|
|
47
47
|
@created_at = created_at
|
|
48
48
|
@updated_at = updated_at
|
|
49
|
+
@temporal = temporal
|
|
50
|
+
@valid_from = valid_from
|
|
51
|
+
@valid_to = valid_to
|
|
49
52
|
@symmetric = symmetric
|
|
50
53
|
freeze
|
|
51
54
|
end
|
|
@@ -54,6 +57,10 @@ module Pglpg
|
|
|
54
57
|
|
|
55
58
|
def [](name) = properties[name.to_sym]
|
|
56
59
|
|
|
60
|
+
attr_reader :valid_from, :valid_to
|
|
61
|
+
|
|
62
|
+
def temporal? = @temporal
|
|
63
|
+
|
|
57
64
|
def symmetric? = @symmetric
|
|
58
65
|
|
|
59
66
|
# The endpoint opposite +ref+ (the same node for a self-loop).
|
data/lib/pglpg/errors.rb
CHANGED
|
@@ -4,12 +4,31 @@ module Pglpg
|
|
|
4
4
|
# Base class for every error pglpg raises.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Text MATCH errors report byte positions in the submitted query.
|
|
8
|
+
class ParseError < Error
|
|
9
|
+
attr_reader :code, :position, :line, :column, :token
|
|
10
|
+
|
|
11
|
+
def initialize(message, code:, text:, position:, token:)
|
|
12
|
+
@code = code
|
|
13
|
+
@position = position
|
|
14
|
+
prefix = text.byteslice(0, position).b
|
|
15
|
+
@line = prefix.count("\n") + 1
|
|
16
|
+
@column = position - (prefix.rindex("\n") || -1)
|
|
17
|
+
@token = token&.freeze
|
|
18
|
+
super("#{message} at line #{line}, column #{column}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
7
22
|
# The graph type declaration is invalid.
|
|
8
23
|
class SchemaError < Error; end
|
|
9
24
|
|
|
10
25
|
# pglpg is used without a required policy or setting.
|
|
11
26
|
class ConfigurationError < Error; end
|
|
12
27
|
|
|
28
|
+
class ReadOnlyContext < Error; end
|
|
29
|
+
class TemporalModeRequired < Error; end
|
|
30
|
+
class TemporalMigrationRequired < Error; end
|
|
31
|
+
|
|
13
32
|
# A Ref belongs to another graph or is the wrong kind of element.
|
|
14
33
|
class InvalidRef < Error; end
|
|
15
34
|
|
data/lib/pglpg/graph.rb
CHANGED
|
@@ -41,8 +41,8 @@ module Pglpg
|
|
|
41
41
|
|
|
42
42
|
# The only entry point for queries and writes. The returned context is
|
|
43
43
|
# immutable and carries the viewer to everything built from it.
|
|
44
|
-
def for(viewer:)
|
|
45
|
-
Context.new(self, viewer)
|
|
44
|
+
def for(viewer:, as_of: nil)
|
|
45
|
+
Context.new(self, viewer, as_of:)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def ref(kind, id) = Ref.new(graph: name, kind:, id:)
|
|
@@ -115,6 +115,9 @@ module Pglpg
|
|
|
115
115
|
properties: edge_type ? edge_type.decode_properties(row["properties"]) : row["properties"].transform_keys(&:to_sym).freeze,
|
|
116
116
|
attrs: decode_attrs(type.edge_columns, row),
|
|
117
117
|
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"]),
|
|
118
|
+
temporal: edge_type&.temporal? || false,
|
|
119
|
+
valid_from: edge_type&.temporal? && row["valid_from"] ? Date.iso8601(row["valid_from"], Date::GREGORIAN).freeze : nil,
|
|
120
|
+
valid_to: edge_type&.temporal? && row["valid_to"] ? Date.iso8601(row["valid_to"], Date::GREGORIAN).freeze : nil,
|
|
118
121
|
symmetric: edge_type&.symmetric? || false
|
|
119
122
|
)
|
|
120
123
|
end
|