pglpg 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +249 -0
- data/README.md +269 -3
- data/lib/pglpg/configuration.rb +4 -1
- data/lib/pglpg/context.rb +71 -13
- data/lib/pglpg/element.rb +23 -3
- data/lib/pglpg/errors.rb +4 -0
- data/lib/pglpg/graph.rb +13 -6
- data/lib/pglpg/model/base.rb +337 -0
- data/lib/pglpg/model/edge.rb +169 -0
- data/lib/pglpg/model/node.rb +207 -0
- data/lib/pglpg/model/relation.rb +334 -0
- data/lib/pglpg/model.rb +58 -0
- data/lib/pglpg/query.rb +68 -15
- data/lib/pglpg/reachability.rb +133 -0
- data/lib/pglpg/schema/edge_type.rb +41 -8
- data/lib/pglpg/schema/element_type.rb +80 -7
- data/lib/pglpg/schema/graph_type.rb +23 -8
- data/lib/pglpg/schema/key.rb +82 -0
- data/lib/pglpg/schema/node_type.rb +7 -22
- data/lib/pglpg/storage.rb +147 -20
- data/lib/pglpg/temporal.rb +48 -0
- data/lib/pglpg/testing/leak_examples.rb +67 -18
- data/lib/pglpg/testing.rb +131 -2
- data/lib/pglpg/version.rb +1 -1
- data/lib/pglpg/writer.rb +127 -43
- data/lib/pglpg.rb +3 -0
- metadata +9 -1
data/lib/pglpg/context.rb
CHANGED
|
@@ -4,11 +4,12 @@ module Pglpg
|
|
|
4
4
|
# A graph seen by one viewer. Created by Graph#for; immutable. Every query
|
|
5
5
|
# and write built from it applies the graph's policies for this viewer.
|
|
6
6
|
class Context
|
|
7
|
-
attr_reader :graph, :viewer
|
|
7
|
+
attr_reader :graph, :viewer, :as_of
|
|
8
8
|
|
|
9
|
-
def initialize(graph, viewer)
|
|
9
|
+
def initialize(graph, viewer, as_of: nil)
|
|
10
10
|
@graph = graph
|
|
11
11
|
@viewer = viewer
|
|
12
|
+
@as_of = as_of.equal?(:any) ? :any : Temporal.date(as_of, error: ArgumentError)
|
|
12
13
|
freeze
|
|
13
14
|
end
|
|
14
15
|
|
|
@@ -23,6 +24,7 @@ module Pglpg
|
|
|
23
24
|
graph.check_ref!(ref, ref.kind)
|
|
24
25
|
return match.node(:n, ref:).first&.fetch(:n) if ref.node?
|
|
25
26
|
|
|
27
|
+
temporal_mode!(graph.type.edge_types.values)
|
|
26
28
|
sql = Sql.new(graph.connection)
|
|
27
29
|
e = "e"
|
|
28
30
|
sql << "SELECT to_jsonb(e) AS e FROM " << table_sql(graph.edges_table) << " AS e"
|
|
@@ -33,6 +35,7 @@ module Pglpg
|
|
|
33
35
|
sql << " WHERE e.id = "
|
|
34
36
|
sql.bind(ref.id, "bigint") << " AND "
|
|
35
37
|
append_edge_visible(sql, e)
|
|
38
|
+
append_temporal(sql, e, graph.type.edge_types.values)
|
|
36
39
|
row = read(sql).first
|
|
37
40
|
row && graph.build_edge(JSON.parse(row["e"]))
|
|
38
41
|
end
|
|
@@ -40,21 +43,44 @@ module Pglpg
|
|
|
40
43
|
# The visible node of +type+ (or a subtype) with the given key, or nil.
|
|
41
44
|
# ctx.find_node(:Person, uid: "PER-12")
|
|
42
45
|
def find_node(type, **key)
|
|
46
|
+
key = key.transform_keys(&:to_sym)
|
|
43
47
|
node_type = graph.type.node_type!(type)
|
|
44
|
-
|
|
45
|
-
raise ArgumentError, "#{type} is looked up by #{node_type.key&.property.inspect}"
|
|
46
|
-
end
|
|
48
|
+
raise ArgumentError, "#{type} has no key" unless node_type.key
|
|
47
49
|
|
|
50
|
+
node_type.key.validate_values!(node_type, key)
|
|
48
51
|
match.node(:n, type, **key).first&.fetch(:n)
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# Lookup by an exact edge property key. Compiler guards apply to the
|
|
55
|
+
# edge, both endpoints and the context's selected date.
|
|
56
|
+
def find_edge(type, **key)
|
|
57
|
+
edge_type = graph.type.edge_type!(type)
|
|
58
|
+
raise ArgumentError, "#{type} has no property key" unless edge_type.key
|
|
59
|
+
|
|
60
|
+
key = key.transform_keys(&:to_sym)
|
|
61
|
+
edge_type.key.validate_values!(edge_type, key)
|
|
62
|
+
query = match.node(:s)
|
|
63
|
+
query = query.public_send(edge_type.symmetric? ? :both : :out, type, :t, edge: :e)
|
|
64
|
+
query.where(e: key).return(:e).first&.fetch(:e)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Visible nodes reachable from +from+ within +max_depth+ hops over the
|
|
68
|
+
# edge types in +via+, each with its minimum depth (a Reachability).
|
|
69
|
+
# Hidden nodes and edges are never traversed. The traversal is one
|
|
70
|
+
# statement; +deadline+ (seconds) can only shorten the configured
|
|
71
|
+
# statement timeout. See ADR-0009.
|
|
72
|
+
# ctx.reachable(from: ref, via: %i[KNOWS], max_depth: 3, max_nodes: 500)
|
|
73
|
+
def reachable(from:, via:, max_depth:, max_nodes:, direction: :out, deadline: nil)
|
|
74
|
+
Reachable.new(self, from:, via:, direction:, max_depth:, max_nodes:, deadline:).call
|
|
75
|
+
end
|
|
76
|
+
|
|
51
77
|
def insert_node(type, properties: {}, attrs: {}) = writer.insert_node(type, properties:, attrs:)
|
|
52
78
|
|
|
53
79
|
def update_node(ref, properties: {}, attrs: {}) = writer.update_node(ref, properties:, attrs:)
|
|
54
80
|
|
|
55
|
-
def insert_edge(type, from:, to:, properties: {}, attrs: {}) = writer.insert_edge(type, from:, to:, properties:, attrs
|
|
81
|
+
def insert_edge(type, from:, to:, properties: {}, attrs: {}, **validity) = writer.insert_edge(type, from:, to:, properties:, attrs:, **validity)
|
|
56
82
|
|
|
57
|
-
def update_edge(ref, properties: {}, attrs: {}) = writer.update_edge(ref, properties:, attrs
|
|
83
|
+
def update_edge(ref, properties: {}, attrs: {}, **validity) = writer.update_edge(ref, properties:, attrs:, **validity)
|
|
58
84
|
|
|
59
85
|
def delete_edge(ref) = writer.delete_edge(ref)
|
|
60
86
|
|
|
@@ -63,13 +89,43 @@ module Pglpg
|
|
|
63
89
|
# callable approves this context. It does not bypass validation, the
|
|
64
90
|
# write policy or endpoint checks.
|
|
65
91
|
def trusted_writer
|
|
92
|
+
writable!
|
|
66
93
|
allowed = graph.trusted_writer_allowed
|
|
67
94
|
raise NotTrusted, "trusted writer not granted" unless allowed.respond_to?(:call) && allowed.call(self) == true
|
|
68
95
|
|
|
69
96
|
Writer.new(self, trusted: true)
|
|
70
97
|
end
|
|
71
98
|
|
|
72
|
-
def inspect
|
|
99
|
+
def inspect
|
|
100
|
+
value = as_of.instance_of?(Date) ? as_of.iso8601 : as_of.inspect
|
|
101
|
+
mode = " as_of=#{value}" unless as_of.nil?
|
|
102
|
+
"#<Pglpg::Context #{graph.name} viewer=#{viewer.inspect}#{mode}>"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def writable!
|
|
106
|
+
raise ReadOnlyContext, "dated contexts are read-only" if as_of.instance_of?(Date)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def temporal_mode!(types)
|
|
110
|
+
if as_of.nil? && types.any?(&:temporal?)
|
|
111
|
+
raise TemporalModeRequired, "temporal reads require as_of: Date or :any"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Appended alongside visibility, before every join/expansion/terminal.
|
|
116
|
+
def append_temporal(sql, name, types)
|
|
117
|
+
temporal_mode!(types)
|
|
118
|
+
temporal = types.select(&:temporal?)
|
|
119
|
+
return unless as_of.instance_of?(Date) && temporal.any?
|
|
120
|
+
|
|
121
|
+
sql << " AND ("
|
|
122
|
+
if temporal.size != types.size
|
|
123
|
+
sql << name << ".type <> ALL("
|
|
124
|
+
sql.bind_text_array(temporal.map(&:name)) << ") OR "
|
|
125
|
+
end
|
|
126
|
+
sql << "daterange(" << name << ".valid_from, " << name << ".valid_to, '[)') @> "
|
|
127
|
+
sql.bind(as_of.iso8601, "date") << ")"
|
|
128
|
+
end
|
|
73
129
|
|
|
74
130
|
# -- internal ------------------------------------------------------------
|
|
75
131
|
|
|
@@ -88,13 +144,15 @@ module Pglpg
|
|
|
88
144
|
sql << ")"
|
|
89
145
|
end
|
|
90
146
|
|
|
91
|
-
# Runs a read under the configured statement
|
|
92
|
-
|
|
147
|
+
# Runs a read under +timeout+ (ms; default: the configured statement
|
|
148
|
+
# timeout). nil leaves the session's timeout as it is. The read always
|
|
149
|
+
# runs in a savepoint, so a timeout (ours or the session's) never leaves
|
|
150
|
+
# the caller's transaction aborted.
|
|
151
|
+
def read(sql, timeout: Pglpg.configuration.statement_timeout)
|
|
93
152
|
graph.with_connection do |c|
|
|
94
|
-
timeout = Pglpg.configuration.statement_timeout
|
|
95
|
-
next c.select_all(sql.to_s, "Pglpg", sql.binds).to_a if timeout.nil?
|
|
96
|
-
|
|
97
153
|
c.transaction(requires_new: true) do
|
|
154
|
+
next c.select_all(sql.to_s, "Pglpg", sql.binds).to_a if timeout.nil?
|
|
155
|
+
|
|
98
156
|
previous = c.select_value("SELECT current_setting('statement_timeout')")
|
|
99
157
|
c.select_value("SELECT set_config('statement_timeout', '#{Integer(timeout)}', true)")
|
|
100
158
|
rows = c.select_all(sql.to_s, "Pglpg", sql.binds).to_a
|
data/lib/pglpg/element.rb
CHANGED
|
@@ -31,11 +31,13 @@ module Pglpg
|
|
|
31
31
|
def inspect = "#<Pglpg::Node #{ref} #{key_label} #{properties.inspect}>"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# An edge read from the graph. Values are frozen.
|
|
34
|
+
# An edge read from the graph. Values are frozen. For a symmetric edge type,
|
|
35
|
+
# source and target are the stored (canonical) endpoints and carry no
|
|
36
|
+
# direction; use #other to walk from one end to the other.
|
|
35
37
|
class Edge
|
|
36
38
|
attr_reader :ref, :type, :source, :target, :properties, :attrs, :created_at, :updated_at
|
|
37
39
|
|
|
38
|
-
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:)
|
|
40
|
+
def initialize(ref:, type:, source:, target:, properties:, attrs:, created_at:, updated_at:, symmetric: false, temporal: false, valid_from: nil, valid_to: nil)
|
|
39
41
|
@ref = ref
|
|
40
42
|
@type = type
|
|
41
43
|
@source = source
|
|
@@ -44,6 +46,10 @@ module Pglpg
|
|
|
44
46
|
@attrs = attrs
|
|
45
47
|
@created_at = created_at
|
|
46
48
|
@updated_at = updated_at
|
|
49
|
+
@temporal = temporal
|
|
50
|
+
@valid_from = valid_from
|
|
51
|
+
@valid_to = valid_to
|
|
52
|
+
@symmetric = symmetric
|
|
47
53
|
freeze
|
|
48
54
|
end
|
|
49
55
|
|
|
@@ -51,12 +57,26 @@ module Pglpg
|
|
|
51
57
|
|
|
52
58
|
def [](name) = properties[name.to_sym]
|
|
53
59
|
|
|
60
|
+
attr_reader :valid_from, :valid_to
|
|
61
|
+
|
|
62
|
+
def temporal? = @temporal
|
|
63
|
+
|
|
64
|
+
def symmetric? = @symmetric
|
|
65
|
+
|
|
66
|
+
# The endpoint opposite +ref+ (the same node for a self-loop).
|
|
67
|
+
def other(ref)
|
|
68
|
+
return target if ref == source
|
|
69
|
+
return source if ref == target
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "#{ref} is not an endpoint of #{self.ref}"
|
|
72
|
+
end
|
|
73
|
+
|
|
54
74
|
def ==(other) = other.is_a?(Edge) && other.ref == ref
|
|
55
75
|
|
|
56
76
|
alias eql? ==
|
|
57
77
|
|
|
58
78
|
def hash = ref.hash
|
|
59
79
|
|
|
60
|
-
def inspect = "#<Pglpg::Edge #{ref} #{type} #{source.id}
|
|
80
|
+
def inspect = "#<Pglpg::Edge #{ref} #{type} #{source.id}#{symmetric? ? "--" : "->"}#{target.id} #{properties.inspect}>"
|
|
61
81
|
end
|
|
62
82
|
end
|
data/lib/pglpg/errors.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Pglpg
|
|
|
10
10
|
# pglpg is used without a required policy or setting.
|
|
11
11
|
class ConfigurationError < Error; end
|
|
12
12
|
|
|
13
|
+
class ReadOnlyContext < Error; end
|
|
14
|
+
class TemporalModeRequired < Error; end
|
|
15
|
+
class TemporalMigrationRequired < Error; end
|
|
16
|
+
|
|
13
17
|
# A Ref belongs to another graph or is the wrong kind of element.
|
|
14
18
|
class InvalidRef < Error; end
|
|
15
19
|
|
data/lib/pglpg/graph.rb
CHANGED
|
@@ -18,15 +18,15 @@ module Pglpg
|
|
|
18
18
|
attr_reader :name, :type, :storage
|
|
19
19
|
|
|
20
20
|
def self.define(name, nodes_table: nil, edges_table: nil, visibility: nil, write_policy: nil,
|
|
21
|
-
trusted_writer_allowed: nil, connection_class: nil, &block)
|
|
21
|
+
trusted_writer_allowed: nil, key_scope_allowed: nil, connection_class: nil, &block)
|
|
22
22
|
type = Schema::GraphType.new
|
|
23
23
|
Schema::Builder.new(type).instance_eval(&block) if block
|
|
24
24
|
type.resolve!
|
|
25
25
|
new(name, type, nodes_table: nodes_table || "#{name}_nodes", edges_table: edges_table || "#{name}_edges",
|
|
26
|
-
visibility:, write_policy:, trusted_writer_allowed:, connection_class:)
|
|
26
|
+
visibility:, write_policy:, trusted_writer_allowed:, key_scope_allowed:, connection_class:)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(name, type, nodes_table:, edges_table:, visibility:, write_policy:, trusted_writer_allowed:,
|
|
29
|
+
def initialize(name, type, nodes_table:, edges_table:, visibility:, write_policy:, trusted_writer_allowed:, key_scope_allowed:,
|
|
30
30
|
connection_class:)
|
|
31
31
|
@name = Schema.check_name!(name, "graph")
|
|
32
32
|
@type = type
|
|
@@ -34,14 +34,15 @@ module Pglpg
|
|
|
34
34
|
@visibility = visibility
|
|
35
35
|
@write_policy = write_policy
|
|
36
36
|
@trusted_writer_allowed = trusted_writer_allowed
|
|
37
|
+
@key_scope_allowed = key_scope_allowed
|
|
37
38
|
@connection_class = connection_class
|
|
38
39
|
freeze
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
# The only entry point for queries and writes. The returned context is
|
|
42
43
|
# immutable and carries the viewer to everything built from it.
|
|
43
|
-
def for(viewer:)
|
|
44
|
-
Context.new(self, viewer)
|
|
44
|
+
def for(viewer:, as_of: nil)
|
|
45
|
+
Context.new(self, viewer, as_of:)
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def ref(kind, id) = Ref.new(graph: name, kind:, id:)
|
|
@@ -77,6 +78,8 @@ module Pglpg
|
|
|
77
78
|
|
|
78
79
|
def trusted_writer_allowed = @trusted_writer_allowed || Pglpg.configuration.trusted_writer_allowed
|
|
79
80
|
|
|
81
|
+
def key_scope_allowed = @key_scope_allowed.nil? ? Pglpg.configuration.key_scope_allowed : @key_scope_allowed
|
|
82
|
+
|
|
80
83
|
def connection_class = @connection_class || Pglpg.configuration.connection_class || ActiveRecord::Base
|
|
81
84
|
|
|
82
85
|
def with_connection(&) = connection_class.with_connection(&)
|
|
@@ -111,7 +114,11 @@ module Pglpg
|
|
|
111
114
|
source: ref(:node, row["source_id"]), target: ref(:node, row["target_id"]),
|
|
112
115
|
properties: edge_type ? edge_type.decode_properties(row["properties"]) : row["properties"].transform_keys(&:to_sym).freeze,
|
|
113
116
|
attrs: decode_attrs(type.edge_columns, row),
|
|
114
|
-
created_at: Time.iso8601(row["created_at"]), updated_at: Time.iso8601(row["updated_at"])
|
|
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,
|
|
121
|
+
symmetric: edge_type&.symmetric? || false
|
|
115
122
|
)
|
|
116
123
|
end
|
|
117
124
|
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pglpg
|
|
4
|
+
module Model
|
|
5
|
+
# Shared by Model::Node and Model::Edge. A model class binds a graph and
|
|
6
|
+
# a schema type; its attributes are the type's properties and the
|
|
7
|
+
# graph's application columns. Models keep the Context they were read
|
|
8
|
+
# or built through, and write through its guarded write API (ADR-0010).
|
|
9
|
+
class Base
|
|
10
|
+
include ActiveModel::API
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
include ActiveModel::Dirty
|
|
13
|
+
|
|
14
|
+
# Attributes are assigned as given: no form coercion ("42" stays a
|
|
15
|
+
# String). The schema checks values on validation.
|
|
16
|
+
#
|
|
17
|
+
# Every conversion copies Strings and Arrays, so the dirty baseline and
|
|
18
|
+
# the caller's object never share the record's value, and in-place
|
|
19
|
+
# edits (tags << "b", name << "e") count as changes.
|
|
20
|
+
class StrictType < ActiveModel::Type::Value
|
|
21
|
+
def cast(value) = copy(value)
|
|
22
|
+
|
|
23
|
+
def deserialize(value) = copy(value)
|
|
24
|
+
|
|
25
|
+
def serialize(value) = copy(value)
|
|
26
|
+
|
|
27
|
+
def changed_in_place?(raw_old_value, new_value) = raw_old_value != new_value
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def copy(value)
|
|
32
|
+
case value
|
|
33
|
+
when Array then value.map { copy(_1) }
|
|
34
|
+
when String then value.dup
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
STRICT = StrictType.new
|
|
41
|
+
|
|
42
|
+
CORE_OWNERS = [Kernel, Object, BasicObject].freeze
|
|
43
|
+
CORE_METHODS = %i[
|
|
44
|
+
class hash freeze frozen? send public_send object_id method methods respond_to? dup clone
|
|
45
|
+
equal? eql? is_a? kind_of? instance_of? nil? itself instance_variable_get instance_variable_set
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
validate :validate_schema
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Binds the model to a graph and a schema type:
|
|
52
|
+
# graph Kg, type: :Person
|
|
53
|
+
# Without arguments, returns the bound graph.
|
|
54
|
+
def graph(graph = nil, type: nil)
|
|
55
|
+
return @graph if graph.nil? && type.nil?
|
|
56
|
+
raise ConfigurationError, "#{self} is already bound to graph #{@graph.name}" if @graph
|
|
57
|
+
raise ArgumentError, "graph must be a Pglpg::Graph" unless graph.is_a?(Pglpg::Graph)
|
|
58
|
+
|
|
59
|
+
schema_type = resolve_type(graph, type)
|
|
60
|
+
check_parent!(graph, schema_type)
|
|
61
|
+
property_names = schema_type.properties.keys
|
|
62
|
+
column_names = element_columns(graph).keys
|
|
63
|
+
check_attribute_names!(schema_type, property_names, column_names)
|
|
64
|
+
|
|
65
|
+
property_names.each { attribute(_1, STRICT) }
|
|
66
|
+
column_names.each { attribute(_1, STRICT) }
|
|
67
|
+
if kind == :edge && schema_type.temporal?
|
|
68
|
+
Temporal::NAMES.each do |name|
|
|
69
|
+
if instance_methods(false).include?(name) || instance_methods(false).include?(:"#{name}=")
|
|
70
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with temporal metadata"
|
|
71
|
+
end
|
|
72
|
+
attribute(name, Edge::BOUND)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
@graph = graph
|
|
76
|
+
@schema_type = schema_type
|
|
77
|
+
@property_names = property_names.freeze
|
|
78
|
+
@column_names = column_names.freeze
|
|
79
|
+
Model.register(graph, kind, schema_type.name, self)
|
|
80
|
+
graph
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :schema_type, :property_names, :column_names
|
|
84
|
+
|
|
85
|
+
# The model's elements as seen through +context+: a Relation.
|
|
86
|
+
def in(context)
|
|
87
|
+
raise ConfigurationError, "#{self} is not bound to a graph; declare graph Kg, type: ..." unless @graph
|
|
88
|
+
raise ArgumentError, "expected a Pglpg::Context" unless context.is_a?(Pglpg::Context)
|
|
89
|
+
raise ArgumentError, "the context belongs to graph #{context.graph.name}, not #{@graph.name}" unless context.graph.equal?(@graph)
|
|
90
|
+
|
|
91
|
+
Relation.new(self, context)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Anonymous model classes are named after their schema type, so
|
|
95
|
+
# validation messages work for them too.
|
|
96
|
+
def model_name
|
|
97
|
+
return super if name
|
|
98
|
+
|
|
99
|
+
@model_name ||= ActiveModel::Name.new(self, nil, schema_type&.name&.to_s || base_class.name.split("::").last)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# -- internal ----------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# A model of +element+, read through +context+: the model registered
|
|
105
|
+
# for its concrete type, else this one.
|
|
106
|
+
def instantiate(context, element)
|
|
107
|
+
klass = Model.lookup(graph, kind, element_type_name(element)) || self
|
|
108
|
+
klass.allocate.tap { _1.send(:load_element, context, element) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A new record bound to +context+.
|
|
112
|
+
def build(context, attributes)
|
|
113
|
+
new(attributes).tap { _1.instance_variable_set(:@context, context) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# A model may extend another model only along the schema's inheritance,
|
|
119
|
+
# checked against the nearest bound ancestor (unbound classes between
|
|
120
|
+
# them still inherit its attributes).
|
|
121
|
+
def check_parent!(graph, schema_type)
|
|
122
|
+
parent = superclass
|
|
123
|
+
parent = parent.superclass while parent < Base && parent.schema_type.nil?
|
|
124
|
+
return unless parent < Base
|
|
125
|
+
return if parent.graph.equal?(graph) && inherits_type?(schema_type, parent.schema_type)
|
|
126
|
+
|
|
127
|
+
raise SchemaError, "#{self} (#{schema_type.name}) cannot extend #{parent} (#{parent.schema_type.name})"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_attribute_names!(schema_type, property_names, column_names)
|
|
131
|
+
clashes = property_names & column_names
|
|
132
|
+
raise SchemaError, "#{schema_type.name} has properties named like columns: #{clashes.join(", ")}" if clashes.any?
|
|
133
|
+
|
|
134
|
+
(property_names + column_names).each do |name|
|
|
135
|
+
if inherited_member_names.include?(name)
|
|
136
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with the inherited association #{name}"
|
|
137
|
+
end
|
|
138
|
+
next unless reserved_name?(name)
|
|
139
|
+
|
|
140
|
+
raise SchemaError, "#{schema_type.name}.#{name} clashes with a method of #{base_class}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Names a model inherits that attributes may not take (associations).
|
|
145
|
+
def inherited_member_names = []
|
|
146
|
+
|
|
147
|
+
# Methods of the model classes and ActiveModel, public or private, plus
|
|
148
|
+
# the core Object methods an attribute must never shadow. Other
|
|
149
|
+
# Kernel and Object methods (format, display, ...) may be shadowed.
|
|
150
|
+
def reserved_name?(name)
|
|
151
|
+
[name, :"#{name}="].any? do |m|
|
|
152
|
+
next true if CORE_METHODS.include?(m)
|
|
153
|
+
next false unless base_class.method_defined?(m) || base_class.private_method_defined?(m)
|
|
154
|
+
|
|
155
|
+
!CORE_OWNERS.include?(base_class.instance_method(m).owner)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
attr_reader :context, :ref, :created_at, :updated_at
|
|
161
|
+
|
|
162
|
+
# Properties stored on the element that this model's type does not
|
|
163
|
+
# declare (a subtype read through a base-type model). Read-only.
|
|
164
|
+
def extra_properties = @extra_properties || {}.freeze
|
|
165
|
+
|
|
166
|
+
def id = ref&.id
|
|
167
|
+
|
|
168
|
+
def persisted? = !ref.nil? && !destroyed?
|
|
169
|
+
|
|
170
|
+
def new_record? = ref.nil?
|
|
171
|
+
|
|
172
|
+
def destroyed? = @destroyed == true
|
|
173
|
+
|
|
174
|
+
def to_key = persisted? ? [id] : nil
|
|
175
|
+
|
|
176
|
+
# The ref as a String; Relation#find accepts it back.
|
|
177
|
+
def to_param = ref&.to_s
|
|
178
|
+
|
|
179
|
+
# Validates, then writes through the context's guarded write API: an
|
|
180
|
+
# insert for a new record, else an update of the changed attributes
|
|
181
|
+
# only. Returns false when validation fails. Refusals (WriteRejected,
|
|
182
|
+
# NotTrusted), timeouts and database errors raise; changes are kept on
|
|
183
|
+
# any failure. +writer:+ hands over the context's trusted writer, for
|
|
184
|
+
# trusted-only operations; generated keys remain unassignable.
|
|
185
|
+
#
|
|
186
|
+
# Success means the statement succeeded in the caller's transaction.
|
|
187
|
+
# Local state is what was assigned, not a stored snapshot (generated
|
|
188
|
+
# keys and timestamps are unknown until #reload), and a caller's
|
|
189
|
+
# rollback is not tracked: discard or reload the record after one.
|
|
190
|
+
def save(writer: nil)
|
|
191
|
+
target = write_target(writer)
|
|
192
|
+
return false unless valid?
|
|
193
|
+
|
|
194
|
+
persist(target)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# As #save, but raises RecordInvalid when validation fails.
|
|
198
|
+
def save!(writer: nil)
|
|
199
|
+
target = write_target(writer)
|
|
200
|
+
raise RecordInvalid, self unless valid?
|
|
201
|
+
|
|
202
|
+
persist(target)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Deletes the element through the guarded write API (a node only
|
|
206
|
+
# through the trusted writer). Returns true.
|
|
207
|
+
def destroy(writer: nil)
|
|
208
|
+
context&.writable!
|
|
209
|
+
raise ArgumentError, "only a persisted record can be destroyed" unless persisted?
|
|
210
|
+
|
|
211
|
+
delete_element(write_target(writer))
|
|
212
|
+
@association_cache = nil
|
|
213
|
+
@destroyed = true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Replaces attributes, timestamps and the dirty baseline from the
|
|
217
|
+
# stored element. Raises RecordNotFound, leaving the record unchanged,
|
|
218
|
+
# when it is missing or no longer visible.
|
|
219
|
+
def reload
|
|
220
|
+
element = ref && context&.find(ref)
|
|
221
|
+
raise RecordNotFound unless element && self.class.matches?(element)
|
|
222
|
+
|
|
223
|
+
load_element(context, element)
|
|
224
|
+
self
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def ==(other) = other.is_a?(self.class.base_class) && !ref.nil? && other.ref == ref
|
|
228
|
+
|
|
229
|
+
alias eql? ==
|
|
230
|
+
|
|
231
|
+
def hash = ref ? ref.hash : super
|
|
232
|
+
|
|
233
|
+
def inspect
|
|
234
|
+
attrs = attributes.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
235
|
+
"#<#{self.class.name || self.class.base_class.name} #{ref || "new"} #{attrs}>"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
# ActiveModel casts lazily, on first read; copy on assignment so a
|
|
241
|
+
# caller who changes the object afterwards doesn't change the record.
|
|
242
|
+
def _write_attribute(name, value) = super(name, STRICT.cast(value))
|
|
243
|
+
|
|
244
|
+
def load_element(context, element)
|
|
245
|
+
@association_cache = nil
|
|
246
|
+
@attributes = self.class._default_attributes.deep_dup
|
|
247
|
+
@context = context
|
|
248
|
+
@ref = element.ref
|
|
249
|
+
@created_at = element.created_at
|
|
250
|
+
@updated_at = element.updated_at
|
|
251
|
+
properties = element.properties
|
|
252
|
+
self.class.property_names.each { @attributes.write_from_database(_1.to_s, properties[_1]) }
|
|
253
|
+
self.class.column_names.each { @attributes.write_from_database(_1.to_s, element.attrs[_1]) }
|
|
254
|
+
@extra_properties = properties.except(*self.class.property_names).freeze
|
|
255
|
+
load_identity(element)
|
|
256
|
+
clear_changes_information
|
|
257
|
+
self
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The context's interactive writes, or the given writer, which must
|
|
261
|
+
# belong to this record's own context (identity).
|
|
262
|
+
def write_target(writer)
|
|
263
|
+
raise ArgumentError, "#{self.class} has no context; build it with Model.in(ctx).new" unless context
|
|
264
|
+
context.writable!
|
|
265
|
+
raise ArgumentError, "#{self.class} was destroyed" if destroyed?
|
|
266
|
+
return context if writer.nil?
|
|
267
|
+
raise ArgumentError, "writer: expects a Pglpg::Writer" unless writer.is_a?(Pglpg::Writer)
|
|
268
|
+
raise ArgumentError, "the writer belongs to another context" unless writer.context.equal?(context)
|
|
269
|
+
|
|
270
|
+
writer
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def persist(target)
|
|
274
|
+
if new_record?
|
|
275
|
+
create_element(target, assigned(self.class.property_names), assigned(self.class.column_names))
|
|
276
|
+
elsif changed?
|
|
277
|
+
update_element(target, changes_to(self.class.property_names), changes_to(self.class.column_names))
|
|
278
|
+
end
|
|
279
|
+
changes_applied
|
|
280
|
+
@association_cache = nil
|
|
281
|
+
true
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Non-nil values to insert (nil means absent).
|
|
285
|
+
def assigned(names)
|
|
286
|
+
names.each_with_object({}) do |name, out|
|
|
287
|
+
value = attribute(name.to_s)
|
|
288
|
+
out[name] = value unless value.nil?
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Changed values to update (nil removes a property).
|
|
293
|
+
def changes_to(names) = names.select { attribute_changed?(_1.to_s) }.to_h { [_1, attribute(_1.to_s)] }
|
|
294
|
+
|
|
295
|
+
def element_columns = self.class.send(:element_columns, self.class.graph)
|
|
296
|
+
|
|
297
|
+
# Schema checks: property values per the stored type, required
|
|
298
|
+
# properties and columns.
|
|
299
|
+
def validate_schema
|
|
300
|
+
type = schema_type
|
|
301
|
+
self.class.property_names.each do |name|
|
|
302
|
+
prop = type.property_named(name)
|
|
303
|
+
value = attribute(name.to_s)
|
|
304
|
+
if value.nil?
|
|
305
|
+
errors.add(name, :blank) if prop.required? && required_now?(name)
|
|
306
|
+
else
|
|
307
|
+
begin
|
|
308
|
+
prop.encode(value)
|
|
309
|
+
rescue ValidationError
|
|
310
|
+
errors.add(name, :invalid, message: "is not a valid #{prop.type == :list ? "list of #{prop.of}" : prop.type}")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
element_columns.each_value do |column|
|
|
315
|
+
next unless attribute(column.name.to_s).nil?
|
|
316
|
+
|
|
317
|
+
blank = new_record? ? column.required? : attribute_changed?(column.name.to_s) && !column.null?
|
|
318
|
+
errors.add(column.name, :blank) if blank
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# A missing required property is an error on insert, and when an
|
|
323
|
+
# update would remove it.
|
|
324
|
+
def required_now?(name) = new_record? || attribute_changed?(name.to_s)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# save! or create! found validation errors. #record has them.
|
|
328
|
+
class RecordInvalid < Error
|
|
329
|
+
attr_reader :record
|
|
330
|
+
|
|
331
|
+
def initialize(record)
|
|
332
|
+
@record = record
|
|
333
|
+
super("validation failed: #{record.errors.full_messages.join(", ")}")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|