pglpg 0.1.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 +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/lib/pglpg/condition.rb +33 -0
- data/lib/pglpg/configuration.rb +48 -0
- data/lib/pglpg/context.rb +120 -0
- data/lib/pglpg/element.rb +62 -0
- data/lib/pglpg/errors.rb +40 -0
- data/lib/pglpg/graph.rb +132 -0
- data/lib/pglpg/query.rb +411 -0
- data/lib/pglpg/ref.rb +25 -0
- data/lib/pglpg/schema/column.rb +38 -0
- data/lib/pglpg/schema/edge_type.rb +35 -0
- data/lib/pglpg/schema/element_type.rb +65 -0
- data/lib/pglpg/schema/graph_type.rb +100 -0
- data/lib/pglpg/schema/node_type.rb +92 -0
- data/lib/pglpg/schema/property.rb +123 -0
- data/lib/pglpg/sql.rb +75 -0
- data/lib/pglpg/storage.rb +208 -0
- data/lib/pglpg/testing/leak_examples.rb +121 -0
- data/lib/pglpg/testing.rb +64 -0
- data/lib/pglpg/version.rb +5 -0
- data/lib/pglpg/writer.rb +329 -0
- data/lib/pglpg.rb +27 -0
- metadata +95 -0
data/lib/pglpg/query.rb
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# A fixed-length pattern query, built immutably from Context#match.
|
|
5
|
+
#
|
|
6
|
+
# ctx.match
|
|
7
|
+
# .node(:p, :Person, uid: "PER-12")
|
|
8
|
+
# .out(:WORKS_ON, :j, :Project, edge: :w)
|
|
9
|
+
# .out(:BASED_AT, :l, :Location)
|
|
10
|
+
# .where(j: { headcount: Pglpg.gt(0) })
|
|
11
|
+
# .return(:p, :j, :l)
|
|
12
|
+
# .order(j: :headcount)
|
|
13
|
+
# .limit(50)
|
|
14
|
+
#
|
|
15
|
+
# The visibility policy is applied to every node and edge in the pattern.
|
|
16
|
+
# Each result row is a frozen Hash from variable to Node or Edge.
|
|
17
|
+
class Query
|
|
18
|
+
include Enumerable
|
|
19
|
+
|
|
20
|
+
NodePattern = Data.define(:var, :label, :ref_id)
|
|
21
|
+
EdgePattern = Data.define(:var, :type, :direction, :from_var, :to_var, :named)
|
|
22
|
+
State = Data.define(:nodes, :edges, :conditions, :returns, :orders, :limit, :offset, :distinct, :last_var)
|
|
23
|
+
|
|
24
|
+
EMPTY = State.new(nodes: {}.freeze, edges: [].freeze, conditions: {}.freeze, returns: nil, orders: [].freeze,
|
|
25
|
+
limit: nil, offset: nil, distinct: false, last_var: nil)
|
|
26
|
+
|
|
27
|
+
attr_reader :context
|
|
28
|
+
|
|
29
|
+
def initialize(context, state = EMPTY)
|
|
30
|
+
@context = context
|
|
31
|
+
@state = state
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The start node. Properties given as keywords are equality conditions.
|
|
36
|
+
def node(var, label = nil, ref: nil, **props)
|
|
37
|
+
raise ArgumentError, "a pattern has one start node; branch with from:" unless @state.nodes.empty?
|
|
38
|
+
|
|
39
|
+
var = new_var!(var)
|
|
40
|
+
label = check_label!(label)
|
|
41
|
+
context.graph.check_ref!(ref, :node) if ref
|
|
42
|
+
q = with(nodes: { var => NodePattern.new(var:, label:, ref_id: ref&.id) }.freeze, last_var: var)
|
|
43
|
+
props.empty? ? q : q.where(var => props)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Follows an outgoing edge of +type+ from +from+ (default: the last node).
|
|
47
|
+
def out(type, var, label = nil, edge: nil, from: nil, **props) = hop(:out, type, var, label, edge, from, props)
|
|
48
|
+
|
|
49
|
+
# Follows an incoming edge of +type+ into +from+ (default: the last node).
|
|
50
|
+
def in(type, var, label = nil, edge: nil, from: nil, **props) = hop(:in, type, var, label, edge, from, props)
|
|
51
|
+
|
|
52
|
+
# Structured conditions per variable: where(j: { headcount: Pglpg.gt(0) }).
|
|
53
|
+
def where(by_var)
|
|
54
|
+
conditions = @state.conditions.transform_values(&:dup)
|
|
55
|
+
by_var.each do |var, props|
|
|
56
|
+
var = var.to_sym
|
|
57
|
+
element_type = element_type!(var)
|
|
58
|
+
props.each do |name, value|
|
|
59
|
+
prop = element_type.property_named(name) or
|
|
60
|
+
raise ArgumentError, "#{element_type.name} has no property #{name.inspect}"
|
|
61
|
+
raise ArgumentError, "list property #{prop.name} cannot be filtered in 0.1" unless prop.comparable?
|
|
62
|
+
|
|
63
|
+
condition = Condition.wrap(value)
|
|
64
|
+
validate_condition!(prop, condition)
|
|
65
|
+
(conditions[var] ||= []) << [prop, condition]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
with(conditions: conditions.transform_values(&:freeze).freeze)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def return(*vars)
|
|
72
|
+
vars = vars.flatten.map(&:to_sym)
|
|
73
|
+
raise ArgumentError, "return needs at least one variable" if vars.empty?
|
|
74
|
+
|
|
75
|
+
vars.each { |v| raise ArgumentError, "#{v} is not a returnable variable" unless returnable.include?(v) }
|
|
76
|
+
with(returns: vars.uniq.freeze)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# order(j: :headcount) or order(j: { name: :desc }). Ties are broken by
|
|
80
|
+
# element ids, so the order is deterministic.
|
|
81
|
+
def order(by_var)
|
|
82
|
+
orders = @state.orders.dup
|
|
83
|
+
by_var.each do |var, spec|
|
|
84
|
+
var = var.to_sym
|
|
85
|
+
element_type = element_type!(var)
|
|
86
|
+
spec = { spec => :asc } unless spec.is_a?(Hash)
|
|
87
|
+
spec.each do |name, dir|
|
|
88
|
+
prop = element_type.property_named(name) or
|
|
89
|
+
raise ArgumentError, "#{element_type.name} has no property #{name.inspect}"
|
|
90
|
+
raise ArgumentError, "direction must be :asc or :desc" unless %i[asc desc].include?(dir)
|
|
91
|
+
raise ArgumentError, "list property #{prop.name} cannot be ordered" unless prop.comparable?
|
|
92
|
+
|
|
93
|
+
orders << [var, prop, dir]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
with(orders: orders.freeze)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def limit(n)
|
|
100
|
+
raise ArgumentError, "limit must be a positive Integer" unless n.is_a?(Integer) && n.positive?
|
|
101
|
+
raise ArgumentError, "limit exceeds max_rows (#{max_rows})" if n > max_rows
|
|
102
|
+
|
|
103
|
+
with(limit: n)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def offset(n)
|
|
107
|
+
raise ArgumentError, "offset must be a non-negative Integer" unless n.is_a?(Integer) && !n.negative?
|
|
108
|
+
|
|
109
|
+
with(offset: n)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def distinct = with(distinct: true)
|
|
113
|
+
|
|
114
|
+
def to_a
|
|
115
|
+
raise ArgumentError, "the pattern has no start node" if @state.nodes.empty?
|
|
116
|
+
|
|
117
|
+
rows = context.read(compile(:rows))
|
|
118
|
+
if @state.limit.nil? && rows.size > max_rows
|
|
119
|
+
raise TooManyRows, "more than #{max_rows} rows; add a limit"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
rows.map { |row| decode(row) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def each(&) = to_a.each(&)
|
|
126
|
+
|
|
127
|
+
def first = limit(1).to_a.first
|
|
128
|
+
|
|
129
|
+
def count = context.read(compile(:count)).first["count"].to_i
|
|
130
|
+
|
|
131
|
+
def exists? = context.read(compile(:exists)).first["exists"] == true
|
|
132
|
+
|
|
133
|
+
# The SQL and bind values, for tests and debugging.
|
|
134
|
+
def to_sql
|
|
135
|
+
sql = compile(:rows)
|
|
136
|
+
[sql.to_s, sql.binds.map(&:value)]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# EXPLAIN output for the query, one line per entry. Runs under the same
|
|
140
|
+
# statement timeout as other reads, since ANALYZE executes the query.
|
|
141
|
+
def explain(analyze: false, buffers: false)
|
|
142
|
+
options = ["FORMAT TEXT", ("ANALYZE" if analyze), ("BUFFERS" if buffers)].compact.join(", ")
|
|
143
|
+
sql = compile(:rows).prepend("EXPLAIN (#{options}) ")
|
|
144
|
+
context.read(sql).map { _1.fetch("QUERY PLAN") }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def inspect = "#<Pglpg::Query #{to_sql.first}>"
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def with(**changes) = Query.new(context, @state.with(**changes))
|
|
152
|
+
|
|
153
|
+
def max_rows = Pglpg.configuration.max_rows
|
|
154
|
+
|
|
155
|
+
def hop(direction, type, var, label, edge, from, props)
|
|
156
|
+
raise ArgumentError, "start the pattern with node" if @state.nodes.empty?
|
|
157
|
+
|
|
158
|
+
edge_type = context.graph.type.edge_type!(type)
|
|
159
|
+
from = (from || @state.last_var).to_sym
|
|
160
|
+
raise ArgumentError, "#{from} is not a node variable" unless @state.nodes.key?(from)
|
|
161
|
+
|
|
162
|
+
var = var.to_sym
|
|
163
|
+
label = check_label!(label)
|
|
164
|
+
nodes = @state.nodes
|
|
165
|
+
if nodes.key?(var)
|
|
166
|
+
if label && label != nodes[var].label
|
|
167
|
+
raise ArgumentError, "#{var} is already bound with label #{nodes[var].label.inspect}"
|
|
168
|
+
end
|
|
169
|
+
else
|
|
170
|
+
new_var!(var)
|
|
171
|
+
nodes = nodes.merge(var => NodePattern.new(var:, label:, ref_id: nil)).freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Checked against the node map including this hop's new node.
|
|
175
|
+
edge_var = edge ? new_var!(edge, nodes) : :"_e#{@state.edges.size}"
|
|
176
|
+
pattern = EdgePattern.new(var: edge_var, type: edge_type.name, direction:, from_var: from, to_var: var,
|
|
177
|
+
named: !edge.nil?)
|
|
178
|
+
q = with(nodes:, edges: (@state.edges + [pattern]).freeze, last_var: var)
|
|
179
|
+
props.empty? ? q : q.where(var => props)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def new_var!(var, nodes = @state.nodes)
|
|
183
|
+
var = var.to_sym
|
|
184
|
+
raise ArgumentError, "variable names starting with _ are reserved" if var.start_with?("_")
|
|
185
|
+
if nodes.key?(var) || @state.edges.any? { _1.var == var }
|
|
186
|
+
raise ArgumentError, "variable #{var} is already bound"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
var
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def check_label!(label)
|
|
193
|
+
return nil if label.nil?
|
|
194
|
+
|
|
195
|
+
context.graph.type.node_type!(label).name
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The type whose properties a variable may be filtered and ordered by.
|
|
199
|
+
def element_type!(var)
|
|
200
|
+
if (node = @state.nodes[var])
|
|
201
|
+
raise ArgumentError, "give #{var} a label to filter or order by its properties" unless node.label
|
|
202
|
+
|
|
203
|
+
return context.graph.type.node_type!(node.label)
|
|
204
|
+
end
|
|
205
|
+
edge = @state.edges.find { _1.var == var && _1.named }
|
|
206
|
+
raise ArgumentError, "unknown variable #{var.inspect}" unless edge
|
|
207
|
+
|
|
208
|
+
context.graph.type.edge_type!(edge.type)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def returnable = @state.nodes.keys + @state.edges.select(&:named).map(&:var)
|
|
212
|
+
|
|
213
|
+
def returns = @state.returns || returnable
|
|
214
|
+
|
|
215
|
+
# -- compilation -----------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def aliases
|
|
218
|
+
node_aliases = @state.nodes.keys.each_with_index.to_h { |v, i| [v, "n#{i}"] }
|
|
219
|
+
edge_aliases = @state.edges.each_with_index.to_h { |e, i| [e.var, "e#{i}"] }
|
|
220
|
+
node_aliases.merge(edge_aliases)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def compile(kind)
|
|
224
|
+
if @state.distinct && (stray = @state.orders.map(&:first) - returns).any?
|
|
225
|
+
raise ArgumentError, "with distinct, order only by returned variables (not #{stray.join(", ")})"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
names = aliases
|
|
229
|
+
sql = Sql.new(context.graph.connection)
|
|
230
|
+
inner = kind != :rows
|
|
231
|
+
sql << (kind == :count ? "SELECT count(*) AS count FROM (" : "SELECT EXISTS (") if inner
|
|
232
|
+
sql << "SELECT "
|
|
233
|
+
sql << "DISTINCT " if @state.distinct
|
|
234
|
+
select_list(sql, kind, names)
|
|
235
|
+
body(sql, names)
|
|
236
|
+
order_by(sql, names) if kind == :rows
|
|
237
|
+
if kind == :rows
|
|
238
|
+
sql << " LIMIT "
|
|
239
|
+
sql.bind(@state.limit || (max_rows + 1), "bigint")
|
|
240
|
+
elsif @state.limit
|
|
241
|
+
sql << " LIMIT "
|
|
242
|
+
sql.bind(@state.limit, "bigint")
|
|
243
|
+
end
|
|
244
|
+
if @state.offset
|
|
245
|
+
sql << " OFFSET "
|
|
246
|
+
sql.bind(@state.offset, "bigint")
|
|
247
|
+
end
|
|
248
|
+
sql << (kind == :count ? ") AS pglpg_rows" : ") AS \"exists\"") if inner
|
|
249
|
+
sql
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def select_list(sql, kind, names)
|
|
253
|
+
if kind == :rows
|
|
254
|
+
returns.each_with_index do |var, i|
|
|
255
|
+
sql << ", " if i.positive?
|
|
256
|
+
sql << "to_jsonb(" << names.fetch(var) << ") AS " << context.graph.connection.quote_column_name(var.to_s)
|
|
257
|
+
end
|
|
258
|
+
if @state.distinct
|
|
259
|
+
@state.orders.each_with_index do |(var, prop, _), i|
|
|
260
|
+
sql << ", "
|
|
261
|
+
order_expr(sql, names.fetch(var), prop)
|
|
262
|
+
sql << " AS _o#{i}"
|
|
263
|
+
end
|
|
264
|
+
returns.each_with_index { |var, i| sql << ", " << names.fetch(var) << ".id AS _i#{i}" }
|
|
265
|
+
end
|
|
266
|
+
elsif @state.distinct
|
|
267
|
+
returns.each_with_index do |var, i|
|
|
268
|
+
sql << ", " if i.positive?
|
|
269
|
+
sql << names.fetch(var) << ".id"
|
|
270
|
+
end
|
|
271
|
+
else
|
|
272
|
+
sql << "1"
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def body(sql, names)
|
|
277
|
+
nodes_table = context.table_sql(context.graph.nodes_table)
|
|
278
|
+
edges_table = context.table_sql(context.graph.edges_table)
|
|
279
|
+
start = @state.nodes.values.first
|
|
280
|
+
sql << " FROM " << nodes_table << " AS " << names.fetch(start.var)
|
|
281
|
+
bound = [start.var]
|
|
282
|
+
|
|
283
|
+
@state.edges.each_with_index do |edge, i|
|
|
284
|
+
e = names.fetch(edge.var)
|
|
285
|
+
near, far = edge.direction == :out ? %w[source_id target_id] : %w[target_id source_id]
|
|
286
|
+
sql << " JOIN " << edges_table << " AS " << e << " ON " << e << "." << near << " = " << names.fetch(edge.from_var)
|
|
287
|
+
sql << ".id AND " << e << ".type = "
|
|
288
|
+
sql.bind(edge.type.to_s, "text")
|
|
289
|
+
sql << " AND "
|
|
290
|
+
context.append_edge_visible(sql, e)
|
|
291
|
+
conditions_sql(sql, edge.var, e)
|
|
292
|
+
# Different edges (the GQL default match mode).
|
|
293
|
+
@state.edges.first(i).each do |other|
|
|
294
|
+
sql << " AND " << e << ".id <> " << names.fetch(other.var) << ".id" if other.type == edge.type
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if bound.include?(edge.to_var)
|
|
298
|
+
sql << " AND " << e << "." << far << " = " << names.fetch(edge.to_var) << ".id"
|
|
299
|
+
else
|
|
300
|
+
n = names.fetch(edge.to_var)
|
|
301
|
+
sql << " JOIN " << nodes_table << " AS " << n << " ON " << n << ".id = " << e << "." << far << " AND "
|
|
302
|
+
node_conditions(sql, @state.nodes.fetch(edge.to_var), n)
|
|
303
|
+
bound << edge.to_var
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
sql << " WHERE "
|
|
308
|
+
node_conditions(sql, start, names.fetch(start.var))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def node_conditions(sql, node, n)
|
|
312
|
+
context.append_node_visible(sql, n)
|
|
313
|
+
if node.label
|
|
314
|
+
# A literal, so the planner can match partial (key) indexes.
|
|
315
|
+
sql << " AND " << n << ".labels @> ARRAY["
|
|
316
|
+
sql.literal(node.label) << "]::text[]"
|
|
317
|
+
end
|
|
318
|
+
if node.ref_id
|
|
319
|
+
sql << " AND " << n << ".id = "
|
|
320
|
+
sql.bind(node.ref_id, "bigint")
|
|
321
|
+
end
|
|
322
|
+
conditions_sql(sql, node.var, n)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def conditions_sql(sql, var, a)
|
|
326
|
+
key = key_of(var)
|
|
327
|
+
(@state.conditions[var] || []).each do |prop, cond|
|
|
328
|
+
sql << " AND "
|
|
329
|
+
case cond.op
|
|
330
|
+
when :eq
|
|
331
|
+
if key && key.property == prop.name
|
|
332
|
+
# Matches the key's partial unique index.
|
|
333
|
+
sql << a << ".labels @> ARRAY["
|
|
334
|
+
sql.literal(key.declared_on) << "]::text[] AND (" << a << ".properties ->> "
|
|
335
|
+
sql.literal(prop.name) << ") = "
|
|
336
|
+
sql.bind(prop.encode(cond.value), "text")
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
sql << a << ".properties @> "
|
|
340
|
+
sql.bind_json({ prop.name.to_s => prop.encode(cond.value) })
|
|
341
|
+
when :absent
|
|
342
|
+
sql << "(" << a << ".properties -> "
|
|
343
|
+
sql.literal(prop.name) << ") IS NULL"
|
|
344
|
+
when :in
|
|
345
|
+
order_expr(sql, a, prop)
|
|
346
|
+
sql << " = ANY("
|
|
347
|
+
sql.bind_text_array(cond.value.map { encoded_text(prop, _1) })
|
|
348
|
+
sql << "::#{prop.sql_cast}[])"
|
|
349
|
+
else
|
|
350
|
+
order_expr(sql, a, prop)
|
|
351
|
+
sql << " " << Condition::OPS.fetch(cond.op) << " "
|
|
352
|
+
sql.bind(encoded_text(prop, cond.value), "text")
|
|
353
|
+
sql << "::#{prop.sql_cast}"
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def key_of(var)
|
|
359
|
+
label = @state.nodes[var]&.label
|
|
360
|
+
label && context.graph.type.node_type!(label).key
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Validates values up front, so errors surface where the query is built.
|
|
364
|
+
def validate_condition!(prop, condition)
|
|
365
|
+
case condition.op
|
|
366
|
+
when :absent then nil
|
|
367
|
+
when :in
|
|
368
|
+
raise ArgumentError, "#{prop.name}: in needs at least one value" if condition.value.empty?
|
|
369
|
+
|
|
370
|
+
condition.value.each { encoded_text(prop, _1) }
|
|
371
|
+
else encoded_text(prop, condition.value)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def encoded_text(prop, value)
|
|
376
|
+
raise ArgumentError, "#{prop.name}: nil is only allowed as a plain value (absent)" if value.nil?
|
|
377
|
+
|
|
378
|
+
prop.encode(value).to_s
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def order_expr(sql, a, prop)
|
|
382
|
+
sql << "(" << a << ".properties ->> "
|
|
383
|
+
sql.literal(prop.name) << ")::#{prop.sql_cast}"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def order_by(sql, names)
|
|
387
|
+
sql << " ORDER BY "
|
|
388
|
+
parts = 0
|
|
389
|
+
@state.orders.each_with_index do |(var, prop, dir), i|
|
|
390
|
+
sql << ", " if parts.positive?
|
|
391
|
+
@state.distinct ? sql << "_o#{i}" : order_expr(sql, names.fetch(var), prop)
|
|
392
|
+
sql << (dir == :desc ? " DESC NULLS LAST" : " ASC NULLS LAST")
|
|
393
|
+
parts += 1
|
|
394
|
+
end
|
|
395
|
+
id_vars = @state.distinct ? returns : names.keys
|
|
396
|
+
id_vars.each_with_index do |var, i|
|
|
397
|
+
sql << ", " if parts.positive?
|
|
398
|
+
@state.distinct ? sql << "_i#{i}" : sql << names.fetch(var) << ".id"
|
|
399
|
+
parts += 1
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def decode(row)
|
|
404
|
+
returns.to_h do |var|
|
|
405
|
+
json = JSON.parse(row.fetch(var.to_s))
|
|
406
|
+
element = @state.nodes.key?(var) ? context.graph.build_node(json) : context.graph.build_edge(json)
|
|
407
|
+
[var, element]
|
|
408
|
+
end.freeze
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
data/lib/pglpg/ref.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
# An immutable, graph-scoped reference to a node or an edge, backed by the
|
|
5
|
+
# element's bigint id. Business keys are for lookup and upsert only; a Ref
|
|
6
|
+
# is how elements refer to each other.
|
|
7
|
+
Ref = Data.define(:graph, :kind, :id) do
|
|
8
|
+
def initialize(graph:, kind:, id:)
|
|
9
|
+
kind = kind.to_sym
|
|
10
|
+
raise ArgumentError, "kind must be :node or :edge" unless %i[node edge].include?(kind)
|
|
11
|
+
raise ArgumentError, "id must be a positive Integer" unless id.is_a?(Integer) && id.positive?
|
|
12
|
+
|
|
13
|
+
super(graph: graph.to_sym, kind:, id:)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def node? = kind == :node
|
|
17
|
+
|
|
18
|
+
def edge? = kind == :edge
|
|
19
|
+
|
|
20
|
+
# "graph:kind:id", accepted by Graph#parse_ref.
|
|
21
|
+
def to_s = "#{graph}:#{kind}:#{id}"
|
|
22
|
+
|
|
23
|
+
def inspect = "#<Pglpg::Ref #{self}>"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Schema
|
|
5
|
+
# An application column on the nodes or edges table, such as
|
|
6
|
+
# +classification+. Visibility policies filter on these real, indexed
|
|
7
|
+
# columns; writes set them through +attrs:+. Options: +null:+, +default:+,
|
|
8
|
+
# +limit:+, +precision:+, +scale:+ and, for edge columns, +covering: true+.
|
|
9
|
+
class Column
|
|
10
|
+
RESERVED = %i[id key_label labels properties type source_id target_id created_at updated_at].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :name, :type, :options
|
|
13
|
+
|
|
14
|
+
def initialize(name, type, **options)
|
|
15
|
+
@name = Schema.check_name!(name, "column")
|
|
16
|
+
raise SchemaError, "#{@name} is a reserved column name" if RESERVED.include?(@name)
|
|
17
|
+
|
|
18
|
+
@type = type.to_sym
|
|
19
|
+
@options = options.freeze
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def null? = options.fetch(:null, true)
|
|
24
|
+
|
|
25
|
+
# Included in the edge traversal indexes (edge columns only).
|
|
26
|
+
def covering? = options.fetch(:covering, false)
|
|
27
|
+
|
|
28
|
+
def default? = options.key?(:default)
|
|
29
|
+
|
|
30
|
+
# Required on insert: NOT NULL and no default.
|
|
31
|
+
def required? = !null? && !default?
|
|
32
|
+
|
|
33
|
+
def sql_type(connection)
|
|
34
|
+
connection.type_to_sql(type, **options.slice(:limit, :precision, :scale))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Schema
|
|
5
|
+
# An edge type: exactly one label (its name), property types, and the
|
|
6
|
+
# node labels its source and target must carry.
|
|
7
|
+
class EdgeType < ElementType
|
|
8
|
+
attr_reader :from, :to, :unique
|
|
9
|
+
|
|
10
|
+
def initialize(name, from:, to:, unique: nil, symmetric: false, temporal: false)
|
|
11
|
+
super(name)
|
|
12
|
+
raise SchemaError, "symmetric edge types are planned for 0.2 (#{name})" if symmetric
|
|
13
|
+
raise SchemaError, "temporal edge types are planned for 0.3 (#{name})" if temporal
|
|
14
|
+
raise SchemaError, "unique: must be nil or :pair (#{name})" unless [nil, :pair].include?(unique)
|
|
15
|
+
|
|
16
|
+
@from = Schema.check_name!(from, "type")
|
|
17
|
+
@to = Schema.check_name!(to, "type")
|
|
18
|
+
@unique = unique
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# At most one edge of this type between the same source and target.
|
|
22
|
+
def unique_pair? = unique == :pair
|
|
23
|
+
|
|
24
|
+
def resolve!(graph_type)
|
|
25
|
+
[from, to].each do |label|
|
|
26
|
+
next if graph_type.node_types.key?(label)
|
|
27
|
+
|
|
28
|
+
raise SchemaError, "edge #{name} refers to unknown node type #{label}"
|
|
29
|
+
end
|
|
30
|
+
@properties = own_properties.dup.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Schema
|
|
5
|
+
NAME_FORMAT = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
6
|
+
|
|
7
|
+
def self.check_name!(name, what)
|
|
8
|
+
raise SchemaError, "invalid #{what} name #{name.inspect}" unless name.to_s.match?(NAME_FORMAT)
|
|
9
|
+
|
|
10
|
+
name.to_sym
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Shared by node and edge types: declared properties. Types are closed:
|
|
14
|
+
# a property that is not declared is rejected.
|
|
15
|
+
class ElementType
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
def initialize(name)
|
|
19
|
+
@name = Schema.check_name!(name, "type")
|
|
20
|
+
@own_properties = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def property(name, type, required: false, values: nil, of: nil)
|
|
24
|
+
name = Schema.check_name!(name, "property")
|
|
25
|
+
raise SchemaError, "#{self.name}.#{name} is declared twice" if @own_properties.key?(name)
|
|
26
|
+
|
|
27
|
+
@own_properties[name] = Property.new(name, type, required:, values:, of:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def own_properties = @own_properties
|
|
31
|
+
|
|
32
|
+
# All properties, inherited ones included.
|
|
33
|
+
def properties = @properties || @own_properties
|
|
34
|
+
|
|
35
|
+
def property_named(name) = properties[name.to_sym]
|
|
36
|
+
|
|
37
|
+
def required_properties = properties.values.select(&:required?)
|
|
38
|
+
|
|
39
|
+
# Validates +props+ and returns the JSON hash to store. nil values are
|
|
40
|
+
# dropped (null means absent). With +partial+, required properties may be
|
|
41
|
+
# missing but may not be set to nil.
|
|
42
|
+
def encode_properties(props, partial: false)
|
|
43
|
+
props = (props || {}).transform_keys(&:to_sym)
|
|
44
|
+
unknown = props.keys - properties.keys
|
|
45
|
+
raise ValidationError, "#{name} has no propert#{unknown.size == 1 ? "y" : "ies"} #{unknown.join(", ")}" if unknown.any?
|
|
46
|
+
|
|
47
|
+
required_properties.each do |p|
|
|
48
|
+
next if partial && !props.key?(p.name)
|
|
49
|
+
raise ValidationError, "#{name}.#{p.name} is required" if props[p.name].nil?
|
|
50
|
+
end
|
|
51
|
+
props.each_with_object({}) do |(k, v), out|
|
|
52
|
+
encoded = properties.fetch(k).encode(v)
|
|
53
|
+
out[k.to_s] = encoded unless encoded.nil?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decode_properties(json)
|
|
58
|
+
(json || {}).each_with_object({}) do |(k, v), out|
|
|
59
|
+
prop = properties[k.to_sym]
|
|
60
|
+
out[k.to_sym] = prop ? prop.decode(v) : v
|
|
61
|
+
end.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Schema
|
|
5
|
+
# The schema of one graph: its node types, edge types and application
|
|
6
|
+
# columns. Built by Builder and frozen once resolved.
|
|
7
|
+
class GraphType
|
|
8
|
+
attr_reader :node_types, :edge_types, :node_columns, :edge_columns
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@node_types = {}
|
|
12
|
+
@edge_types = {}
|
|
13
|
+
@node_columns = {}
|
|
14
|
+
@edge_columns = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def node_type!(name)
|
|
18
|
+
node_types.fetch(name.to_sym) { raise ArgumentError, "unknown node type #{name.inspect}" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edge_type!(name)
|
|
22
|
+
edge_types.fetch(name.to_sym) { raise ArgumentError, "unknown edge type #{name.inspect}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Concrete node types that carry +label+ (the type itself and its subtypes).
|
|
26
|
+
def node_types_with_label(label)
|
|
27
|
+
node_types.values.select { _1.label?(label) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resolve!
|
|
31
|
+
node_types.each_value { _1.resolve!(self) }
|
|
32
|
+
edge_types.each_value { _1.resolve!(self) }
|
|
33
|
+
[node_types, edge_types, node_columns, edge_columns].each(&:freeze)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The block DSL of Pglpg::Graph.define.
|
|
39
|
+
class Builder
|
|
40
|
+
def initialize(graph_type)
|
|
41
|
+
@graph_type = graph_type
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def node(name, extends: [], abstract: false, &block)
|
|
45
|
+
type = NodeType.new(name, extends:, abstract:)
|
|
46
|
+
raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
|
|
47
|
+
|
|
48
|
+
NodeBuilder.new(type).instance_eval(&block) if block
|
|
49
|
+
@graph_type.node_types[type.name] = type
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def edge(name, from:, to:, unique: nil, symmetric: false, temporal: false, &block)
|
|
53
|
+
type = EdgeType.new(name, from:, to:, unique:, symmetric:, temporal:)
|
|
54
|
+
raise SchemaError, "type #{type.name} is declared twice" if declared?(type.name)
|
|
55
|
+
|
|
56
|
+
ElementBuilder.new(type).instance_eval(&block) if block
|
|
57
|
+
@graph_type.edge_types[type.name] = type
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def node_column(name, type, **options) = add_column(@graph_type.node_columns, name, type, options)
|
|
61
|
+
|
|
62
|
+
def edge_column(name, type, **options) = add_column(@graph_type.edge_columns, name, type, options)
|
|
63
|
+
|
|
64
|
+
# Adds the same application column to both tables.
|
|
65
|
+
def column(name, type, **options)
|
|
66
|
+
node_column(name, type, **options)
|
|
67
|
+
edge_column(name, type, **options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def declared?(name) = @graph_type.node_types.key?(name) || @graph_type.edge_types.key?(name)
|
|
73
|
+
|
|
74
|
+
def add_column(columns, name, type, options)
|
|
75
|
+
column = Column.new(name, type, **options)
|
|
76
|
+
raise SchemaError, "column #{column.name} is declared twice" if columns.key?(column.name)
|
|
77
|
+
|
|
78
|
+
columns[column.name] = column
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class ElementBuilder
|
|
83
|
+
def initialize(type)
|
|
84
|
+
@type = type
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def property(name, type, required: false, values: nil, of: nil)
|
|
88
|
+
@type.property(name, type, required:, values:, of:)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class NodeBuilder < ElementBuilder
|
|
93
|
+
# Declares an exclusive key. Interactive inserts get a value from
|
|
94
|
+
# +generate+ (default: a random UUID); callers never choose it.
|
|
95
|
+
def key(name, generate: nil)
|
|
96
|
+
@type.key_property(name, generate:)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|