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.
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
- unless node_type.key && key.keys == [node_type.key.property]
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 = "#<Pglpg::Context #{graph.name} viewer=#{viewer.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 timeout.
92
- def read(sql)
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}->#{target.id} #{properties.inspect}>"
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