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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ed878f97c7dcf3ffb630de85d798896dcbb6b0729e176ec410eb6383bd4659e
4
- data.tar.gz: '055047480e44e5ff29ccaddce6d3ed730397dcffa243541c42e4c1e5b3c607ac'
3
+ metadata.gz: dcdce167b8e343909f06e710fdb65a1833a5cfa3649c85a475050c830a463a0d
4
+ data.tar.gz: f7725ba5bd6255af98a77875b766ac0e7d3a180f4af7ced79b50446998fbda6d
5
5
  SHA512:
6
- metadata.gz: 516ba2fc4c42f933616e5b2ffae77a4c320f6a1bd400dcd332addb5884f19499bd97d8ce8ceebdaafc9ceba3e52ed822abe5c994e2e9aa072b0ec615a2908ecf
7
- data.tar.gz: 0707c16c9bc2e84093a9423531eb802460c291f61362f5af788074a356f46c264f83419bc815399360231d943ab3f181f51f09247a24442f5bcb02d77fc04b72
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.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.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.2.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.2.0 upgrade notes](CHANGELOG.md) before upgrading from 0.1.
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
- 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
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 = "#<Pglpg::Context #{graph.name} viewer=#{viewer.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