pglpg 0.3.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: 61a05d0f91dc037aefaaf82fa99143eae9a3c2742dd2308adcffa0d364b2f6fc
4
- data.tar.gz: 1286500cadead4a337382e5bba4a5ec60bc861bcec88bfb04c07856aadaa4cf3
3
+ metadata.gz: dcdce167b8e343909f06e710fdb65a1833a5cfa3649c85a475050c830a463a0d
4
+ data.tar.gz: f7725ba5bd6255af98a77875b766ac0e7d3a180f4af7ced79b50446998fbda6d
5
5
  SHA512:
6
- metadata.gz: c76e6ed407cbb7ef56cc9e79999cad98c91d67e7f46b08b2aba548c197cd52409b6d70ae83b029e032c9c3dcda1594a3825241727a0da45c91a553c456bead57
7
- data.tar.gz: 5c4c89d4af4a6f57988a734cc0d154b9b9c96a8dea5a15c51b7ab3802078f27d5468e7f42c6e98a6698befca2cb58c9ce7e5c9cc44e0482470d157951100a804
6
+ metadata.gz: e536165dfa69606c33d7c15c4dd77c68878a3be01b6f8b7b097e4effd3597d26a6b65e2dd1c87d2ce5247c588980bebb779af6e674d4e29d84f3efcf05187b22
7
+ data.tar.gz: 7c6fbe3e465c7134ae05ec5601e61f941462687f60f1dd91242641b1ab22446e44d0f9836c3db4cb2ca6c330f0136ead5cd8c23c305eac300ff9d15374aa645a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 0.3.0 (2026-11-03)
4
13
 
5
14
  ### Breaking
data/README.md CHANGED
@@ -8,16 +8,13 @@ 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.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
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
14
13
  [pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
15
14
  ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
16
15
 
17
16
  ## Install
18
17
 
19
- After 0.3.0 publication:
20
-
21
18
  ```ruby
22
19
  gem "pglpg", "~> 0.3.0"
23
20
  ```
@@ -26,6 +23,26 @@ Before 1.0, minor releases may break compatibility. Read the
26
23
  [0.3.0 upgrade notes](CHANGELOG.md) and the migration steps below before
27
24
  upgrading from 0.2. Applications still on 0.1 must also apply the 0.2 notes.
28
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
+
29
46
  ## Upgrade from 0.2
30
47
 
31
48
  - Choose `as_of: Date` for dated reads or `as_of: :any` for all dates.
@@ -60,7 +77,7 @@ Existing timeless, closed schemas with unchanged keys need no migration solely
60
77
  for the version bump. Rerun your real-policy leak suite and label-drift checks
61
78
  when adopting this minor release.
62
79
 
63
- This candidate implements a documented standards subset: key components are
80
+ This release implements a documented standards subset: key components are
64
81
  string/integer/boolean/enum, open extras are scalars/flat homogeneous lists,
65
82
  and temporal validity is one date interval with current authorization. It does
66
83
  not add alternate/overlap keys, open labels, dynamic query/model attributes,
@@ -232,7 +249,7 @@ row access. Scope and key are immutable. Existing data needs a transactional
232
249
  backfill and constraint migration; see the
233
250
  [API contract](https://knowen.md/nodes/36329).
234
251
 
235
- ## Composite and edge keys (0.3.0 candidate)
252
+ ## Composite and edge keys (0.3.0)
236
253
 
237
254
  Keys can use a tuple of required string, integer, boolean or enum properties:
238
255
 
@@ -278,7 +295,7 @@ by `storage.sync_constraints!` on the same connection. No automatic migration
278
295
  runs when a declaration is loaded. Other property types, optional uniqueness
279
296
  and multiple alternate keys are outside this key API.
280
297
 
281
- ## Open properties (0.3.0 candidate)
298
+ ## Open properties (0.3.0)
282
299
 
283
300
  Types stay closed by default. Opt in to extra properties explicitly:
284
301
 
@@ -326,7 +343,7 @@ Audit/backfill before closing types or promoting extras to typed declarations.
326
343
  Shape validation is at the library write boundary; raw SQL does not gain an
327
344
  open/closed constraint. The existing required-property and key checks remain.
328
345
 
329
- ## Temporal edges (0.3.0 candidate)
346
+ ## Temporal edges (0.3.0)
330
347
 
331
348
  Opt in per edge type and use one date across the whole query:
332
349
 
data/lib/pglpg/context.rb CHANGED
@@ -16,6 +16,8 @@ module Pglpg
16
16
  # Starts a fixed-length pattern query.
17
17
  def match = Query.new(self)
18
18
 
19
+ def parse_match(text, params: {}) = MatchParser.new(self, text, params).parse
20
+
19
21
  # The visible node or edge behind +ref+, or nil. A hidden element and a
20
22
  # missing one look the same.
21
23
  def find(ref)
data/lib/pglpg/errors.rb CHANGED
@@ -4,6 +4,21 @@ 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
 
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Pglpg
6
+ # The text front end builds the same immutable query state as the Ruby DSL.
7
+ class MatchParser
8
+ MAX_BYTES = 8 * 1024
9
+ MAX_HOPS = 8
10
+ MAX_PREDICATES = 32
11
+ MAX_PARAMETERS = 32
12
+
13
+ IDENTIFIER = /[A-Za-z_][A-Za-z_0-9]*/
14
+ PARAMETER = /\$#{IDENTIFIER}/
15
+ TOKEN = /#{PARAMETER}|#{IDENTIFIER}|[0-9]+|->|<-|[^\s]/
16
+ KEYWORDS = %w[MATCH WHERE AND RETURN DISTINCT ORDER BY ASC DESC LIMIT].freeze
17
+ UNSUPPORTED_WORDS = %w[OPTIONAL CREATE INSERT DELETE DETACH SET REMOVE MERGE
18
+ CALL WITH UNION OFFSET SKIP GROUP HAVING].freeze
19
+ CONDITION_PREFIXES = %w[NOT ANY ALL NONE SINGLE EXISTS].freeze
20
+ CONDITION_OPERATORS = %w[IS IN NOT STARTS ENDS CONTAINS LIKE BETWEEN].freeze
21
+ CONDITION_CONNECTORS = %w[OR XOR].freeze
22
+ Token = Data.define(:text, :position)
23
+
24
+ def initialize(context, text, params)
25
+ raise ArgumentError, "MATCH text must be a String" unless text.is_a?(String)
26
+
27
+ @context = context
28
+ @text = text
29
+ @params = params
30
+ @bindings = {}
31
+ @types = {}
32
+ end
33
+
34
+ def parse
35
+ validate_text!
36
+ @scanner = StringScanner.new(@text)
37
+ advance
38
+ expect("MATCH")
39
+ start = node_pattern
40
+ hops = []
41
+ hops << edge_pattern while %w[- <- ~].include?(@token.text)
42
+ fail_at(:unsupported, "multiple patterns are outside the MATCH subset") if @token.text == ","
43
+ predicates = read_predicates
44
+ expect("RETURN")
45
+ distinct = accept("DISTINCT")
46
+ returns = [return_binding]
47
+ returns << return_binding while accept(",")
48
+ order = read_order if accept("ORDER")
49
+ limit = take_integer if accept("LIMIT")
50
+ accept(";")
51
+ expect_end!
52
+
53
+ if distinct && order && returns.none? { |var| var.text == order.first.text }
54
+ fail_at(:syntax, "DISTINCT ordering requires a returned binding", order.first)
55
+ end
56
+ check_cap(hops.size, MAX_HOPS, "hops", hops[MAX_HOPS]&.first)
57
+ check_cap(predicates.size, MAX_PREDICATES, "WHERE predicates", predicates[MAX_PREDICATES]&.first)
58
+ row_limit = validate_limit(limit)
59
+ values = parameter_values(predicates.map(&:last))
60
+
61
+ query = build_pattern(start, hops)
62
+ predicates.each do |var, name, param|
63
+ property!(var, name)
64
+ begin
65
+ query = query.where(var.text.to_sym => { name.text.to_sym => values.fetch(param.text.delete_prefix("$")) })
66
+ rescue ValidationError, ArgumentError, RangeError
67
+ fail_at(:parameter, "invalid equality value", param)
68
+ end
69
+ end
70
+ returns.each { type_for!(_1) }
71
+ query = query.return(*returns.map { _1.text.to_sym })
72
+ query = query.distinct if distinct
73
+ if order
74
+ var, name, direction = order
75
+ property!(var, name)
76
+ query = query.order(var.text.to_sym => { name.text.to_sym => direction })
77
+ end
78
+ row_limit ? query.limit(row_limit) : query
79
+ end
80
+
81
+ private
82
+
83
+ def validate_text!
84
+ unless [Encoding::UTF_8, Encoding::US_ASCII].include?(@text.encoding)
85
+ fail_at(:syntax, "text must use UTF-8 or US-ASCII", Token.new(nil, 0))
86
+ end
87
+ if @text.bytesize > MAX_BYTES
88
+ fail_at(:limit_exceeded, "text exceeds #{MAX_BYTES} bytes", Token.new(nil, MAX_BYTES))
89
+ end
90
+ return if @text.valid_encoding?
91
+
92
+ offset = 0
93
+ @text.each_char do |char|
94
+ fail_at(:syntax, "invalid text encoding", Token.new(nil, offset)) unless char.valid_encoding?
95
+ offset += char.bytesize
96
+ end
97
+ end
98
+
99
+ def advance
100
+ @scanner.skip(/\s+/)
101
+ position = @scanner.pos
102
+ if (comment = @scanner.scan(%r{//|/\*}))
103
+ fail_at(:unsupported, "comments are outside the MATCH subset", Token.new(comment, position))
104
+ end
105
+ @token = Token.new(@scanner.scan(TOKEN), position)
106
+ end
107
+
108
+ def take
109
+ token = @token
110
+ advance
111
+ token
112
+ end
113
+
114
+ def accept(text)
115
+ take if @token.text&.casecmp?(text)
116
+ end
117
+
118
+ def expect(text)
119
+ accept(text) || unexpected("expected #{text}")
120
+ end
121
+
122
+ def expect_end!
123
+ unexpected("expected end of input") if @token.text
124
+ end
125
+
126
+ def unexpected(message)
127
+ text = @token.text
128
+ unsupported = text && (UNSUPPORTED_WORDS.include?(text.upcase) || %w[{ } | & ! * + %].include?(text))
129
+ fail_at(unsupported ? :unsupported : :syntax, message)
130
+ end
131
+
132
+ def identifier
133
+ return take if @token.text&.match?(/\A#{IDENTIFIER}\z/)
134
+
135
+ unexpected("expected an identifier")
136
+ end
137
+
138
+ def binding
139
+ token = identifier
140
+ name = token.text
141
+ if name.start_with?("_") || KEYWORDS.include?(name.upcase) || @bindings.key?(name)
142
+ fail_at(:syntax, "reserved or duplicate binding", token)
143
+ end
144
+ @bindings[name] = true
145
+ token
146
+ end
147
+
148
+ def node_pattern
149
+ expect("(")
150
+ var = binding
151
+ expect(":")
152
+ label = identifier
153
+ expect(")")
154
+ [var, label]
155
+ end
156
+
157
+ def edge_pattern
158
+ connector = take
159
+ direction, ending = case connector.text
160
+ when "-" then [:out, "->"]
161
+ when "<-" then [:in, "-"]
162
+ when "~" then [:both, "~"]
163
+ end
164
+ expect("[")
165
+ var = binding unless @token.text == ":"
166
+ expect(":")
167
+ type = identifier
168
+ expect("]")
169
+ if direction == :out && @token.text == "-"
170
+ fail_at(:unsupported, "use a directed arrow or a symmetric tilde")
171
+ end
172
+ expect(ending)
173
+ [connector, direction, var, type, node_pattern]
174
+ end
175
+
176
+ def read_predicates
177
+ return [] unless accept("WHERE")
178
+
179
+ predicates = []
180
+ loop do
181
+ # Contextual keywords can still name a binding in a property reference.
182
+ prefix = CONDITION_PREFIXES.include?(@token.text&.upcase) && !@scanner.check(/\s*\./)
183
+ if @token.text == "(" || prefix
184
+ fail_at(:unsupported_condition, "only AND-joined equality predicates are supported")
185
+ end
186
+ var, property = property_reference
187
+ unless accept("=")
188
+ fail_at(:unsupported_condition, "only equality predicates are supported") if %w[< > !].include?(@token.text) ||
189
+ CONDITION_OPERATORS.include?(@token.text&.upcase)
190
+ unexpected("expected =")
191
+ end
192
+ unless @token.text&.match?(/\A#{PARAMETER}\z/)
193
+ fail_at(:unsupported_condition, "equality requires a named parameter") if @token.text && @token.text != "$" && !KEYWORDS.include?(@token.text.upcase)
194
+ unexpected("expected a named parameter")
195
+ end
196
+ predicates << [var, property, take]
197
+ fail_at(:unsupported_condition, "only AND conjunctions are supported") if CONDITION_CONNECTORS.include?(@token.text&.upcase)
198
+ break unless accept("AND")
199
+ end
200
+ predicates
201
+ end
202
+
203
+ def property_reference
204
+ var = identifier
205
+ expect(".")
206
+ [var, identifier]
207
+ end
208
+
209
+ def return_binding
210
+ var = identifier
211
+ fail_at(:unsupported, "RETURN accepts element bindings only") if %w[. ( AS].include?(@token.text&.upcase)
212
+ var
213
+ end
214
+
215
+ def read_order
216
+ expect("BY")
217
+ var, name = property_reference
218
+ direction = accept("DESC") ? :desc : :asc
219
+ accept("ASC") if direction == :asc
220
+ fail_at(:unsupported, "ORDER BY accepts one term") if @token.text == ","
221
+ [var, name, direction]
222
+ end
223
+
224
+ def take_integer
225
+ return take if @token.text&.match?(/\A[0-9]+\z/)
226
+
227
+ unexpected("expected a decimal integer")
228
+ end
229
+
230
+ def validate_limit(token)
231
+ return unless token
232
+
233
+ limit = token.text.to_i
234
+ fail_at(:syntax, "LIMIT must be positive", token) if limit.zero?
235
+ check_cap(limit, Pglpg.configuration.max_rows, "LIMIT", token)
236
+ limit
237
+ end
238
+
239
+ def parameter_values(references)
240
+ origin = Token.new(nil, 0)
241
+ fail_at(:parameter, "params must be a Hash", origin) unless @params.is_a?(Hash)
242
+ named = references.to_h { |token| [token.text.delete_prefix("$"), token] }
243
+ # The predicate cap also bounds the number of distinct references.
244
+ check_cap(@params.size, MAX_PARAMETERS, "parameter entries", origin)
245
+
246
+ values = {}
247
+ @params.each do |key, value|
248
+ unless (key.is_a?(String) || key.is_a?(Symbol)) &&
249
+ key.to_s.ascii_only? && key.to_s.match?(/\A#{IDENTIFIER}\z/)
250
+ fail_at(:parameter, "invalid parameter key", origin)
251
+ end
252
+ name = key.to_s
253
+ token = named[name] || origin
254
+ fail_at(:parameter, "duplicate parameter key", token) if values.key?(name)
255
+ fail_at(:parameter, "unused parameter", token) unless named.key?(name)
256
+ if value.nil? || value.is_a?(Array) || value.is_a?(Condition)
257
+ fail_at(:parameter, "equality requires a scalar value", token)
258
+ end
259
+ values[name] = value.is_a?(String) ? value.dup.freeze : value
260
+ end
261
+ named.each { |name, token| fail_at(:parameter, "missing parameter", token) unless values.key?(name) }
262
+ values
263
+ end
264
+
265
+ def build_pattern(start, hops)
266
+ schema = @context.graph.type
267
+ var, label = start
268
+ bind_type(var, label, schema.node_types)
269
+ query = @context.match.node(var.text, label.text)
270
+ hops.each do |connector, direction, edge, type, target|
271
+ edge_type = bind_type(edge, type, schema.edge_types)
272
+ if edge_type.symmetric? != (direction == :both)
273
+ fail_at(:unsupported, "edge direction does not match its type", connector)
274
+ end
275
+ var, label = target
276
+ bind_type(var, label, schema.node_types)
277
+ query = query.public_send(direction, type.text, var.text, label.text, edge: edge&.text)
278
+ end
279
+ query
280
+ end
281
+
282
+ def bind_type(var, name, types)
283
+ type = types[name.text.to_sym] || fail_at(:unknown_name, "unknown label or edge type", name)
284
+ @types[var.text] = type if var
285
+ type
286
+ end
287
+
288
+ def type_for!(var)
289
+ @types[var.text] || fail_at(:unknown_name, "unknown binding", var)
290
+ end
291
+
292
+ def property!(var, name)
293
+ property = type_for!(var).property_named(name.text)
294
+ fail_at(:unknown_name, "unknown declared property", name) unless property
295
+ fail_at(:unsupported, "property is not comparable", name) unless property.comparable?
296
+ end
297
+
298
+ def check_cap(count, max, unit, token)
299
+ fail_at(:limit_exceeded, "maximum #{unit} is #{max}", token) if count > max
300
+ end
301
+
302
+ def fail_at(code, message, token = @token)
303
+ raise ParseError.new(message, code:, text: @text, position: token.position,
304
+ token: token.text || ("EOF" if token.position == @text.bytesize)), cause: nil
305
+ end
306
+ end
307
+ private_constant :MatchParser
308
+ end
data/lib/pglpg/testing.rb CHANGED
@@ -25,6 +25,7 @@ module Pglpg
25
25
  persons: -> { m.().node(:p, :Person).order(p: :name) },
26
26
  works_on: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project, edge: :w) },
27
27
  two_hops: -> { m.().node(:p, :Person).out(:WORKS_ON, :j, :Project).out(:BASED_AT, :l, :Location) },
28
+ parsed_knows_chain: -> { ctx.parse_match("MATCH (a:Person)-[:KNOWS]->(b:Person)-[:KNOWS]->(c:Person) RETURN a, b, c") },
28
29
  knows_chain: -> { m.().node(:a, :Person).out(:KNOWS, :b, :Person).out(:KNOWS, :c, :Person) },
29
30
  inbound: -> { m.().node(:l, :Location).in(:BASED_AT, :j, :Project).in(:WORKS_ON, :p, :Person) },
30
31
  branch: -> { m.().node(:j, :Project).in(:WORKS_ON, :p, :Person).out(:BASED_AT, :l, :Location, from: :j) },
data/lib/pglpg/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pglpg
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/pglpg.rb CHANGED
@@ -23,6 +23,7 @@ require_relative "pglpg/graph"
23
23
  require_relative "pglpg/context"
24
24
  require_relative "pglpg/writer"
25
25
  require_relative "pglpg/query"
26
+ require_relative "pglpg/match_parser"
26
27
  require_relative "pglpg/reachability"
27
28
 
28
29
  # Labeled property graphs stored in plain PostgreSQL tables.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pglpg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Fefilov
@@ -54,6 +54,7 @@ files:
54
54
  - lib/pglpg/element.rb
55
55
  - lib/pglpg/errors.rb
56
56
  - lib/pglpg/graph.rb
57
+ - lib/pglpg/match_parser.rb
57
58
  - lib/pglpg/model.rb
58
59
  - lib/pglpg/model/base.rb
59
60
  - lib/pglpg/model/edge.rb