pglpg 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ed878f97c7dcf3ffb630de85d798896dcbb6b0729e176ec410eb6383bd4659e
4
- data.tar.gz: '055047480e44e5ff29ccaddce6d3ed730397dcffa243541c42e4c1e5b3c607ac'
3
+ metadata.gz: 61a05d0f91dc037aefaaf82fa99143eae9a3c2742dd2308adcffa0d364b2f6fc
4
+ data.tar.gz: 1286500cadead4a337382e5bba4a5ec60bc861bcec88bfb04c07856aadaa4cf3
5
5
  SHA512:
6
- metadata.gz: 516ba2fc4c42f933616e5b2ffae77a4c320f6a1bd400dcd332addb5884f19499bd97d8ce8ceebdaafc9ceba3e52ed822abe5c994e2e9aa072b0ec615a2908ecf
7
- data.tar.gz: 0707c16c9bc2e84093a9423531eb802460c291f61362f5af788074a356f46c264f83419bc815399360231d943ab3f181f51f09247a24442f5bcb02d77fc04b72
6
+ metadata.gz: c76e6ed407cbb7ef56cc9e79999cad98c91d67e7f46b08b2aba548c197cd52409b6d70ae83b029e032c9c3dcda1594a3825241727a0da45c91a553c456bead57
7
+ data.tar.gz: 5c4c89d4af4a6f57988a734cc0d154b9b9c96a8dea5a15c51b7ab3802078f27d5468e7f42c6e98a6698befca2cb58c9ce7e5c9cc44e0482470d157951100a804
data/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
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
+
3
95
  ## 0.2.0 (2026-10-30)
4
96
 
5
97
  ### Breaking
data/README.md CHANGED
@@ -8,21 +8,64 @@ 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.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
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
13
14
  [pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
14
15
  ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
15
16
 
16
17
  ## Install
17
18
 
18
- After publication:
19
+ After 0.3.0 publication:
19
20
 
20
21
  ```ruby
21
- gem "pglpg", "~> 0.2.0"
22
+ gem "pglpg", "~> 0.3.0"
22
23
  ```
23
24
 
24
25
  Before 1.0, minor releases may break compatibility. Read the
25
- [0.2.0 upgrade notes](CHANGELOG.md) before upgrading from 0.1.
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.
26
69
 
27
70
  ## Define a graph
28
71
 
@@ -57,8 +100,8 @@ node id first, and writes accept the endpoints in either order. Self-loops are
57
100
  refused unless the type sets `allow_self_loops: true`.
58
101
 
59
102
  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.
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.
62
105
 
63
106
  In a migration:
64
107
 
@@ -189,6 +232,165 @@ row access. Scope and key are immutable. Existing data needs a transactional
189
232
  backfill and constraint migration; see the
190
233
  [API contract](https://knowen.md/nodes/36329).
191
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
+
192
394
  ## Leak specs for your policy
193
395
 
194
396
  ```ruby
data/lib/pglpg/context.rb CHANGED
@@ -4,11 +4,12 @@ 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
 
@@ -23,6 +24,7 @@ module Pglpg
23
24
  graph.check_ref!(ref, ref.kind)
24
25
  return match.node(:n, ref:).first&.fetch(:n) if ref.node?
25
26
 
27
+ temporal_mode!(graph.type.edge_types.values)
26
28
  sql = Sql.new(graph.connection)
27
29
  e = "e"
28
30
  sql << "SELECT to_jsonb(e) AS e FROM " << table_sql(graph.edges_table) << " AS e"
@@ -33,6 +35,7 @@ module Pglpg
33
35
  sql << " WHERE e.id = "
34
36
  sql.bind(ref.id, "bigint") << " AND "
35
37
  append_edge_visible(sql, e)
38
+ append_temporal(sql, e, graph.type.edge_types.values)
36
39
  row = read(sql).first
37
40
  row && graph.build_edge(JSON.parse(row["e"]))
38
41
  end
@@ -42,13 +45,25 @@ module Pglpg
42
45
  def find_node(type, **key)
43
46
  key = key.transform_keys(&:to_sym)
44
47
  node_type = graph.type.node_type!(type)
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}"
47
- end
48
+ raise ArgumentError, "#{type} has no key" unless node_type.key
48
49
 
50
+ node_type.key.validate_values!(node_type, key)
49
51
  match.node(:n, type, **key).first&.fetch(:n)
50
52
  end
51
53
 
54
+ # Lookup by an exact edge property key. Compiler guards apply to the
55
+ # edge, both endpoints and the context's selected date.
56
+ def find_edge(type, **key)
57
+ edge_type = graph.type.edge_type!(type)
58
+ raise ArgumentError, "#{type} has no property key" unless edge_type.key
59
+
60
+ key = key.transform_keys(&:to_sym)
61
+ edge_type.key.validate_values!(edge_type, key)
62
+ query = match.node(:s)
63
+ query = query.public_send(edge_type.symmetric? ? :both : :out, type, :t, edge: :e)
64
+ query.where(e: key).return(:e).first&.fetch(:e)
65
+ end
66
+
52
67
  # Visible nodes reachable from +from+ within +max_depth+ hops over the
53
68
  # edge types in +via+, each with its minimum depth (a Reachability).
54
69
  # Hidden nodes and edges are never traversed. The traversal is one
@@ -63,9 +78,9 @@ module Pglpg
63
78
 
64
79
  def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
65
80
 
66
- def insert_edge(type, from:, to:, properties: {}, attrs: {}) = writer.insert_edge(type, from:, to:, properties:, attrs:)
81
+ def insert_edge(type, from:, to:, properties: {}, attrs: {}, **validity) = writer.insert_edge(type, from:, to:, properties:, attrs:, **validity)
67
82
 
68
- def update_edge(ref, properties: {}, attrs: {}) = writer.update_edge(ref, properties:, attrs:)
83
+ def update_edge(ref, properties: {}, attrs: {}, **validity) = writer.update_edge(ref, properties:, attrs:, **validity)
69
84
 
70
85
  def delete_edge(ref) = writer.delete_edge(ref)
71
86
 
@@ -74,13 +89,43 @@ module Pglpg
74
89
  # callable approves this context. It does not bypass validation, the
75
90
  # write policy or endpoint checks.
76
91
  def trusted_writer
92
+ writable!
77
93
  allowed = graph.trusted_writer_allowed
78
94
  raise NotTrusted, "trusted writer not granted" unless allowed.respond_to?(:call) && allowed.call(self) == true
79
95
 
80
96
  Writer.new(self, trusted: true)
81
97
  end
82
98
 
83
- def inspect = "#<Pglpg::Context #{graph.name} viewer=#{viewer.inspect}>"
99
+ def inspect
100
+ value = as_of.instance_of?(Date) ? as_of.iso8601 : as_of.inspect
101
+ mode = " as_of=#{value}" unless as_of.nil?
102
+ "#<Pglpg::Context #{graph.name} viewer=#{viewer.inspect}#{mode}>"
103
+ end
104
+
105
+ def writable!
106
+ raise ReadOnlyContext, "dated contexts are read-only" if as_of.instance_of?(Date)
107
+ end
108
+
109
+ def temporal_mode!(types)
110
+ if as_of.nil? && types.any?(&:temporal?)
111
+ raise TemporalModeRequired, "temporal reads require as_of: Date or :any"
112
+ end
113
+ end
114
+
115
+ # Appended alongside visibility, before every join/expansion/terminal.
116
+ def append_temporal(sql, name, types)
117
+ temporal_mode!(types)
118
+ temporal = types.select(&:temporal?)
119
+ return unless as_of.instance_of?(Date) && temporal.any?
120
+
121
+ sql << " AND ("
122
+ if temporal.size != types.size
123
+ sql << name << ".type <> ALL("
124
+ sql.bind_text_array(temporal.map(&:name)) << ") OR "
125
+ end
126
+ sql << "daterange(" << name << ".valid_from, " << name << ".valid_to, '[)') @> "
127
+ sql.bind(as_of.iso8601, "date") << ")"
128
+ end
84
129
 
85
130
  # -- internal ------------------------------------------------------------
86
131
 
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
@@ -10,6 +10,10 @@ module Pglpg
10
10
  # pglpg is used without a required policy or setting.
11
11
  class ConfigurationError < Error; end
12
12
 
13
+ class ReadOnlyContext < Error; end
14
+ class TemporalModeRequired < Error; end
15
+ class TemporalMigrationRequired < Error; end
16
+
13
17
  # A Ref belongs to another graph or is the wrong kind of element.
14
18
  class InvalidRef < Error; end
15
19
 
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
@@ -64,6 +64,14 @@ module Pglpg
64
64
 
65
65
  property_names.each { attribute(_1, STRICT) }
66
66
  column_names.each { attribute(_1, STRICT) }
67
+ if kind == :edge && schema_type.temporal?
68
+ Temporal::NAMES.each do |name|
69
+ if instance_methods(false).include?(name) || instance_methods(false).include?(:"#{name}=")
70
+ raise SchemaError, "#{schema_type.name}.#{name} clashes with temporal metadata"
71
+ end
72
+ attribute(name, Edge::BOUND)
73
+ end
74
+ end
67
75
  @graph = graph
68
76
  @schema_type = schema_type
69
77
  @property_names = property_names.freeze
@@ -173,7 +181,7 @@ module Pglpg
173
181
  # only. Returns false when validation fails. Refusals (WriteRejected,
174
182
  # NotTrusted), timeouts and database errors raise; changes are kept on
175
183
  # any failure. +writer:+ hands over the context's trusted writer, for
176
- # trusted-only operations; it never permits assigning a key.
184
+ # trusted-only operations; generated keys remain unassignable.
177
185
  #
178
186
  # Success means the statement succeeded in the caller's transaction.
179
187
  # Local state is what was assigned, not a stored snapshot (generated
@@ -197,6 +205,7 @@ module Pglpg
197
205
  # Deletes the element through the guarded write API (a node only
198
206
  # through the trusted writer). Returns true.
199
207
  def destroy(writer: nil)
208
+ context&.writable!
200
209
  raise ArgumentError, "only a persisted record can be destroyed" unless persisted?
201
210
 
202
211
  delete_element(write_target(writer))
@@ -252,6 +261,7 @@ module Pglpg
252
261
  # belong to this record's own context (identity).
253
262
  def write_target(writer)
254
263
  raise ArgumentError, "#{self.class} has no context; build it with Model.in(ctx).new" unless context
264
+ context.writable!
255
265
  raise ArgumentError, "#{self.class} was destroyed" if destroyed?
256
266
  return context if writer.nil?
257
267
  raise ArgumentError, "writer: expects a Pglpg::Writer" unless writer.is_a?(Pglpg::Writer)