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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +26 -9
- data/lib/pglpg/context.rb +2 -0
- data/lib/pglpg/errors.rb +15 -0
- data/lib/pglpg/match_parser.rb +308 -0
- data/lib/pglpg/testing.rb +1 -0
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg.rb +1 -0
- metadata +2 -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,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.
|
|
12
|
-
version is 0.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
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.
|
|
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
|