funicular 0.3.0 → 0.5.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 +486 -1
- data/demo/local_notes.html +207 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +227 -12
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +58 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +264 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/sig_tags_test.rb +30 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/minitest/view_context_test.rb +15 -15
- data/mrbgem.rake +2 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +226 -24
- data/mrblib/db.rb +3116 -0
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/form_builder.rb +10 -10
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +122 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +25 -4
- data/sig/db.rbs +328 -0
- data/sig/error_boundary.rbs +4 -4
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +19 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +23 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/model.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# On the browser this is picoruby-uri; under CRuby SSR it is the stdlib URI.
|
|
2
|
+
# Both encode_www_form the same way (space -> "+", byte-wise %XX).
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
1
5
|
module Funicular
|
|
2
6
|
class Model
|
|
3
7
|
include Validations
|
|
@@ -8,9 +12,31 @@ module Funicular
|
|
|
8
12
|
attr_accessor :schema, :endpoints
|
|
9
13
|
end
|
|
10
14
|
|
|
15
|
+
# Every Model subclass registers itself at definition time: the
|
|
16
|
+
# boot (docs decision 19) needs the full declared set -- local
|
|
17
|
+
# models for migrations, replica models for the schema-derived
|
|
18
|
+
# DDL -- without asking the app to enumerate it.
|
|
19
|
+
def self.inherited(subclass)
|
|
20
|
+
Funicular::Model.__register_model(subclass)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.__register_model(subclass)
|
|
24
|
+
registry = (@registered_models ||= []) # steep:ignore UnannotatedEmptyCollection
|
|
25
|
+
registry << subclass
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.__registered_models
|
|
30
|
+
models = Funicular::Model.instance_variable_get(:@registered_models)
|
|
31
|
+
models || []
|
|
32
|
+
end
|
|
33
|
+
|
|
11
34
|
def self.load_schema(schema_data)
|
|
12
35
|
@schema = schema_data["attributes"]
|
|
13
36
|
@endpoints = schema_data["endpoints"]
|
|
37
|
+
# Replica column metadata derives from the schema; drop any cache.
|
|
38
|
+
@local_columns = nil
|
|
39
|
+
__assert_no_association_conflict(@schema.keys)
|
|
14
40
|
|
|
15
41
|
# Generate attr_accessor dynamically based on schema
|
|
16
42
|
@schema.each do |name, config|
|
|
@@ -40,6 +66,10 @@ module Funicular
|
|
|
40
66
|
next unless rules.is_a?(Hash)
|
|
41
67
|
rules.each do |kind, opts|
|
|
42
68
|
options = normalize_validation_options(kind, opts)
|
|
69
|
+
# nil means the options could not be materialized on this runtime
|
|
70
|
+
# (e.g. a format regex the JS RegExp engine rejects): drop that one
|
|
71
|
+
# validator instead of failing the whole schema load.
|
|
72
|
+
next if options.nil?
|
|
43
73
|
add_schema_validator(attribute, kind, options)
|
|
44
74
|
end
|
|
45
75
|
end
|
|
@@ -56,54 +86,876 @@ module Funicular
|
|
|
56
86
|
bits = 0
|
|
57
87
|
bits |= Regexp::IGNORECASE if flags.include?("i")
|
|
58
88
|
bits |= Regexp::MULTILINE if flags.include?("m")
|
|
59
|
-
|
|
89
|
+
begin
|
|
90
|
+
# Annotated so the record type {with: Regexp} does not reject
|
|
91
|
+
# the boolean flags added below.
|
|
92
|
+
normalized = { with: Regexp.new(opts["with"], bits) } #: Hash[Symbol, untyped]
|
|
93
|
+
normalized[:allow_nil] = true if opts["allow_nil"]
|
|
94
|
+
normalized[:allow_blank] = true if opts["allow_blank"]
|
|
95
|
+
normalized
|
|
96
|
+
rescue => e
|
|
97
|
+
# Schema regex translation is best-effort; a source this engine
|
|
98
|
+
# cannot compile must not take down the whole boot sequence.
|
|
99
|
+
# Name the pattern (truncated) so the offending model/attribute
|
|
100
|
+
# can be found among many schemas loading at boot.
|
|
101
|
+
source = opts["with"].to_s
|
|
102
|
+
source = "#{source[0, 60]}..." if 60 < source.length
|
|
103
|
+
puts "[Funicular] Skipping format validator (#{e.class}: #{e.message}; pattern: #{source})"
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
60
106
|
else
|
|
61
107
|
opts
|
|
62
108
|
end
|
|
63
109
|
end
|
|
64
110
|
|
|
111
|
+
# ---- local-database declaration DSL (docs/local_database.md) --------
|
|
112
|
+
#
|
|
113
|
+
# storage :replica (default) server data, mirrored into the replica
|
|
114
|
+
# storage :ephemeral REST only, no local table
|
|
115
|
+
# storage :local do ... end client-only table built by migrate blocks
|
|
116
|
+
#
|
|
117
|
+
# The migrate blocks are only RECORDED at class-definition time (their
|
|
118
|
+
# version rules validated); they execute against the local database at
|
|
119
|
+
# boot, in the migration runner.
|
|
120
|
+
|
|
121
|
+
def self.storage(kind, &block)
|
|
122
|
+
# refresh is a replica-only axis: a local table has no server to
|
|
123
|
+
# refresh from, and an ephemeral model has no table at all. The
|
|
124
|
+
# check lives in BOTH declarations because either can come
|
|
125
|
+
# first in the class body.
|
|
126
|
+
# The message reads the kind through an unnarrowed alias: inside
|
|
127
|
+
# the branch below the comparisons leave `kind` with no type
|
|
128
|
+
# steep will call #inspect on.
|
|
129
|
+
# @type var declared: Symbol
|
|
130
|
+
declared = kind
|
|
131
|
+
if @refresh_mode && (kind == :local || kind == :ephemeral)
|
|
132
|
+
raise ArgumentError,
|
|
133
|
+
"storage #{declared.inspect} cannot follow a refresh " \
|
|
134
|
+
"declaration (refresh applies to replica models only)"
|
|
135
|
+
end
|
|
136
|
+
# A storage change invalidates cached column metadata.
|
|
137
|
+
@local_columns = nil
|
|
138
|
+
if kind == :local
|
|
139
|
+
unless block
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"storage :local requires a block with migrate declarations"
|
|
142
|
+
end
|
|
143
|
+
@storage_kind = kind
|
|
144
|
+
@local_migrations = []
|
|
145
|
+
@collecting_migrations = true
|
|
146
|
+
begin
|
|
147
|
+
block.call
|
|
148
|
+
ensure
|
|
149
|
+
@collecting_migrations = false
|
|
150
|
+
end
|
|
151
|
+
validate_local_migrations
|
|
152
|
+
elsif kind == :replica || kind == :ephemeral
|
|
153
|
+
if block
|
|
154
|
+
raise ArgumentError, "only storage :local takes a block"
|
|
155
|
+
end
|
|
156
|
+
@storage_kind = kind
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"storage must be :replica, :ephemeral, or :local, got #{kind.inspect}"
|
|
160
|
+
end
|
|
161
|
+
kind
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.storage_kind
|
|
165
|
+
@storage_kind || :replica
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.replica?
|
|
169
|
+
storage_kind == :replica
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.ephemeral?
|
|
173
|
+
storage_kind == :ephemeral
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.local?
|
|
177
|
+
storage_kind == :local
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Record one numbered migration block. Only valid inside storage :local.
|
|
181
|
+
def self.migrate(version, reset: false, &block)
|
|
182
|
+
migrations = @local_migrations
|
|
183
|
+
unless @collecting_migrations && migrations
|
|
184
|
+
raise ArgumentError,
|
|
185
|
+
"migrate must be declared inside a storage :local block"
|
|
186
|
+
end
|
|
187
|
+
unless version.is_a?(Integer) && 1 <= version
|
|
188
|
+
raise ArgumentError,
|
|
189
|
+
"migrate version must be a positive Integer, got #{version.inspect}"
|
|
190
|
+
end
|
|
191
|
+
unless block
|
|
192
|
+
raise ArgumentError, "migrate #{version} requires a block"
|
|
193
|
+
end
|
|
194
|
+
migrations << { version: version, reset: reset, block: block }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.local_migrations
|
|
198
|
+
@local_migrations
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Baseline rules (docs): the first retained block is version 1 unless
|
|
202
|
+
# it is `reset: true` (then any positive version); contiguous after
|
|
203
|
+
# that. A gap means a deleted non-baseline block -- fail at class eval.
|
|
204
|
+
def self.validate_local_migrations
|
|
205
|
+
migrations = @local_migrations
|
|
206
|
+
if migrations.nil? || migrations.empty?
|
|
207
|
+
raise ArgumentError, "storage :local requires at least one migrate block"
|
|
208
|
+
end
|
|
209
|
+
first = migrations[0]
|
|
210
|
+
unless first[:version] == 1 || first[:reset]
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"the first migrate block must be version 1 (or reset: true), " \
|
|
213
|
+
"got #{first[:version]}"
|
|
214
|
+
end
|
|
215
|
+
i = 1
|
|
216
|
+
while i < migrations.size
|
|
217
|
+
expected = migrations[i - 1][:version] + 1
|
|
218
|
+
unless migrations[i][:version] == expected
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"migrate versions must be contiguous: expected #{expected}, " \
|
|
221
|
+
"got #{migrations[i][:version]}"
|
|
222
|
+
end
|
|
223
|
+
i += 1
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# v1 implements only the default :manual (freshness is explicit fetch);
|
|
228
|
+
# :auto and :live are reserved and rejected at class-definition time.
|
|
229
|
+
def self.refresh(mode)
|
|
230
|
+
kind = @storage_kind
|
|
231
|
+
if kind == :local || kind == :ephemeral
|
|
232
|
+
raise ArgumentError,
|
|
233
|
+
"refresh does not apply to storage #{storage_kind.inspect} " \
|
|
234
|
+
"(refresh applies to replica models only)"
|
|
235
|
+
end
|
|
236
|
+
if mode == :auto
|
|
237
|
+
raise NotImplementedError,
|
|
238
|
+
"refresh :auto is not yet supported (v1 is :manual only)"
|
|
239
|
+
end
|
|
240
|
+
if mode == :live
|
|
241
|
+
raise NotImplementedError,
|
|
242
|
+
"refresh :live is not yet supported (v1 is :manual only)"
|
|
243
|
+
end
|
|
244
|
+
unless mode == :manual
|
|
245
|
+
raise ArgumentError, "refresh must be :manual, got #{mode.inspect}"
|
|
246
|
+
end
|
|
247
|
+
@refresh_mode = mode
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.refresh_mode
|
|
251
|
+
@refresh_mode || :manual
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ---- associations (docs/local_database.md, "Associations") ----------
|
|
255
|
+
#
|
|
256
|
+
# Local-query sugar over the <name>_id convention, and nothing more:
|
|
257
|
+
#
|
|
258
|
+
# post.user -> User.local.find_by(id: post.user_id)
|
|
259
|
+
# post.comments -> Comment.local.where(post_id: post.id)
|
|
260
|
+
#
|
|
261
|
+
# Readers only. The foreign key is assigned the plain way
|
|
262
|
+
# (post.user_id = user.id), so there is no second, hidden path into
|
|
263
|
+
# the column and no dirty-tracking special case.
|
|
264
|
+
|
|
265
|
+
def self.belongs_to(name, class_name: nil, foreign_key: nil, **rest)
|
|
266
|
+
__reject_association_options(:belongs_to, name, rest)
|
|
267
|
+
key = name.to_sym
|
|
268
|
+
target = (class_name || __camelize(key.to_s)).to_s
|
|
269
|
+
fk = (foreign_key || "#{key}_id").to_s
|
|
270
|
+
__register_association(key)
|
|
271
|
+
define_method(key) do
|
|
272
|
+
# @type self: Model
|
|
273
|
+
value = send(fk)
|
|
274
|
+
if value.nil?
|
|
275
|
+
# No foreign key, no target row -- and no query to run.
|
|
276
|
+
nil
|
|
277
|
+
else
|
|
278
|
+
self.class.__association_target(key, target).local.find_by(id: value)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def self.has_many(name, class_name: nil, foreign_key: nil, **rest)
|
|
285
|
+
__reject_association_options(:has_many, name, rest)
|
|
286
|
+
key = name.to_sym
|
|
287
|
+
target = (class_name || __camelize(__singularize(key.to_s))).to_s
|
|
288
|
+
fk = (foreign_key || "#{demodulized_snake_name}_id").to_s
|
|
289
|
+
__register_association(key)
|
|
290
|
+
define_method(key) do
|
|
291
|
+
# @type self: Model
|
|
292
|
+
# An unsaved parent owns nothing: the empty-array condition
|
|
293
|
+
# compiles to 1=0, where a nil would read as IS NULL and hand
|
|
294
|
+
# this record every orphan row in the table.
|
|
295
|
+
none = [] #: Array[untyped]
|
|
296
|
+
value = id.nil? ? none : id
|
|
297
|
+
self.class.__association_target(key, target).local.where(fk => value)
|
|
298
|
+
end
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def self.__reject_association_options(kind, name, rest)
|
|
303
|
+
return nil if rest.empty?
|
|
304
|
+
raise ArgumentError,
|
|
305
|
+
"#{kind} #{name.to_sym.inspect}: #{rest.keys.inspect} not supported " \
|
|
306
|
+
"in v1 (only class_name: and foreign_key:; through, eager loading, " \
|
|
307
|
+
"and polymorphic associations are not implemented)"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The registry exists for two readers: the memoized target class,
|
|
311
|
+
# and the column/attribute collision guard.
|
|
312
|
+
def self.__register_association(key)
|
|
313
|
+
registry = (@associations ||= {}) # steep:ignore UnannotatedEmptyCollection
|
|
314
|
+
# Last-one-wins would be the same silent shrug as ignoring
|
|
315
|
+
# through:: the reader defined first is simply gone.
|
|
316
|
+
if registry.has_key?(key)
|
|
317
|
+
raise ArgumentError,
|
|
318
|
+
"#{self}: the association #{key.inspect} is already declared"
|
|
319
|
+
end
|
|
320
|
+
__assert_association_name_free(key)
|
|
321
|
+
registry[key] = { klass: nil }
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Unlike every other generated accessor, an association reader is
|
|
326
|
+
# defined unconditionally -- so `has_many :errors` would replace
|
|
327
|
+
# Validations#errors, and valid? (which calls errors.clear) would
|
|
328
|
+
# die on a Relation, at neither declaration nor first read.
|
|
329
|
+
#
|
|
330
|
+
# The reserved set is Funicular::Model's OWN instance API: a
|
|
331
|
+
# subclass's column accessors live on the subclass, so they can
|
|
332
|
+
# never appear here, whenever they are generated. Subtracting
|
|
333
|
+
# Object's keeps generic names (hash, send) out of it. A method the
|
|
334
|
+
# model itself hand-wrote is deliberately NOT covered -- catching
|
|
335
|
+
# it would only work when the `def` precedes the declaration.
|
|
336
|
+
def self.__assert_association_name_free(key)
|
|
337
|
+
# One fixed set per process; the framework's API stops changing
|
|
338
|
+
# once mrblib is loaded, well before any app model declares.
|
|
339
|
+
reserved = Funicular::Model.instance_variable_get(:@reserved_names) ||
|
|
340
|
+
Funicular::Model.instance_variable_set(
|
|
341
|
+
:@reserved_names,
|
|
342
|
+
Funicular::Model.instance_methods - Object.instance_methods)
|
|
343
|
+
return nil unless reserved.include?(key)
|
|
344
|
+
raise ArgumentError,
|
|
345
|
+
"#{self}: the association #{key.inspect} would replace " \
|
|
346
|
+
"Funicular::Model##{key}; pick another name"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def self.__associations
|
|
350
|
+
@associations || {}
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Resolved lazily, at first read: model files load in sorted filename
|
|
354
|
+
# order, so `belongs_to :user` in post.rb must not demand the User
|
|
355
|
+
# constant while user.rb is still unloaded. Only successes are
|
|
356
|
+
# memoized -- a class that loads later still resolves.
|
|
357
|
+
def self.__association_target(key, class_name)
|
|
358
|
+
entry = __associations[key]
|
|
359
|
+
cached = entry && entry[:klass]
|
|
360
|
+
return cached if cached
|
|
361
|
+
klass = __resolve_association_constant(key, class_name)
|
|
362
|
+
entry[:klass] = klass if entry
|
|
363
|
+
klass
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Convention-derived target names never carry "::", so the walk is
|
|
367
|
+
# here for class_name: strings alone.
|
|
368
|
+
def self.__resolve_association_constant(key, class_name)
|
|
369
|
+
parts = class_name.split("::")
|
|
370
|
+
# @type var owner: untyped
|
|
371
|
+
owner = Object
|
|
372
|
+
i = 0
|
|
373
|
+
while i < parts.size
|
|
374
|
+
part = parts[i].to_sym
|
|
375
|
+
# inherit: false. A class used as a namespace inherits from
|
|
376
|
+
# Object, so the default lookup would answer "Admin::User" with
|
|
377
|
+
# the top-level ::User when Admin carries no User of its own --
|
|
378
|
+
# a typo resolving to a DIFFERENT model, in the one path whose
|
|
379
|
+
# job is to fail clearly. const_get takes no such argument here
|
|
380
|
+
# (picoruby: one argument only), but it does not need one: an
|
|
381
|
+
# own constant wins the lookup, and the check below is what
|
|
382
|
+
# decides whether there is one.
|
|
383
|
+
unless owner.is_a?(Module) && owner.const_defined?(part, false)
|
|
384
|
+
raise NameError, __unresolvable_association(key, class_name)
|
|
385
|
+
end
|
|
386
|
+
owner = owner.const_get(part)
|
|
387
|
+
i += 1
|
|
388
|
+
end
|
|
389
|
+
# A module or a plain value has no `.local` behind it; same
|
|
390
|
+
# verdict, same message.
|
|
391
|
+
unless owner.is_a?(Class)
|
|
392
|
+
raise NameError, __unresolvable_association(key, class_name)
|
|
393
|
+
end
|
|
394
|
+
owner
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def self.__unresolvable_association(key, class_name)
|
|
398
|
+
"#{self}: association #{key.inspect} cannot resolve the model " \
|
|
399
|
+
"class #{class_name}; declare class_name:, or make sure the model " \
|
|
400
|
+
"is one the client carries"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# A column (or REST attribute) named like a declared association:
|
|
404
|
+
# the association reader would shadow it silently while the
|
|
405
|
+
# generated writer kept writing the column. Raised where the two
|
|
406
|
+
# first meet -- the accessor generation, which is the first instance
|
|
407
|
+
# path on local models and the schema load on the others.
|
|
408
|
+
def self.__assert_no_association_conflict(names)
|
|
409
|
+
registry = @associations
|
|
410
|
+
return nil unless registry
|
|
411
|
+
i = 0
|
|
412
|
+
while i < names.size
|
|
413
|
+
name = names[i]
|
|
414
|
+
if registry.has_key?(name.to_sym)
|
|
415
|
+
raise ArgumentError,
|
|
416
|
+
"#{self}: the association #{name.to_sym.inspect} and the " \
|
|
417
|
+
"attribute of the same name would shadow each other; rename one"
|
|
418
|
+
end
|
|
419
|
+
i += 1
|
|
420
|
+
end
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Reader and override in one: `table_name` returns the table name
|
|
425
|
+
# (naive pluralization of the class name: +s, y->ies),
|
|
426
|
+
# `table_name "things"` overrides it.
|
|
427
|
+
def self.table_name(explicit = nil)
|
|
428
|
+
if explicit
|
|
429
|
+
@table_name = explicit.to_s
|
|
430
|
+
else
|
|
431
|
+
@table_name ||= derive_table_name
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def self.derive_table_name
|
|
436
|
+
__pluralize(demodulized_snake_name)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# "Admin::BlogPost" -> "blog_post". The singular half of
|
|
440
|
+
# derive_table_name; has_many reuses it for the foreign key.
|
|
441
|
+
def self.demodulized_snake_name
|
|
442
|
+
parts = to_s.split("::")
|
|
443
|
+
base = parts[parts.size - 1] || ""
|
|
444
|
+
snake = ""
|
|
445
|
+
i = 0
|
|
446
|
+
while i < base.length
|
|
447
|
+
c = base.getbyte(i)
|
|
448
|
+
if c && 65 <= c && c <= 90 # A-Z
|
|
449
|
+
snake += "_" unless i == 0
|
|
450
|
+
snake += (c + 32).chr
|
|
451
|
+
else
|
|
452
|
+
char = base[i]
|
|
453
|
+
snake += char if char
|
|
454
|
+
end
|
|
455
|
+
i += 1
|
|
456
|
+
end
|
|
457
|
+
snake
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def self.__pluralize(name)
|
|
461
|
+
if name.end_with?("y")
|
|
462
|
+
"#{name[0, name.length - 1]}ies"
|
|
463
|
+
else
|
|
464
|
+
"#{name}s"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The exact inverse of __pluralize. Irregular names are the caller's
|
|
469
|
+
# job, through class_name: -- the same deal table_name offers.
|
|
470
|
+
def self.__singularize(name)
|
|
471
|
+
if name.end_with?("ies")
|
|
472
|
+
"#{name[0, name.length - 3]}y"
|
|
473
|
+
elsif name.end_with?("s")
|
|
474
|
+
name[0, name.length - 1].to_s
|
|
475
|
+
else
|
|
476
|
+
name
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def self.__camelize(name)
|
|
481
|
+
parts = name.split("_")
|
|
482
|
+
out = ""
|
|
483
|
+
i = 0
|
|
484
|
+
while i < parts.size
|
|
485
|
+
part = parts[i]
|
|
486
|
+
head = part[0]
|
|
487
|
+
if head
|
|
488
|
+
out += head.upcase
|
|
489
|
+
out += part[1, part.length - 1].to_s
|
|
490
|
+
end
|
|
491
|
+
i += 1
|
|
492
|
+
end
|
|
493
|
+
out
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# The marked local/cache view (source-of-truth contract): a Relation
|
|
497
|
+
# over the whole local table. Ephemeral models have no table behind it.
|
|
498
|
+
def self.local
|
|
499
|
+
if ephemeral?
|
|
500
|
+
raise Funicular::DB::NoTableError,
|
|
501
|
+
"#{to_s} is storage :ephemeral; it has no local table"
|
|
502
|
+
end
|
|
503
|
+
Relation.new(self)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# ---- Relation protocol (see mrblib/relation.rb) ---------------------
|
|
507
|
+
|
|
508
|
+
# Column name -> declared type. For replica models this derives from
|
|
509
|
+
# the server schema: binary attributes never reach the replica and the
|
|
510
|
+
# id type follows the server. Local models fold their migrate blocks
|
|
511
|
+
# (pure metadata; works before boot).
|
|
512
|
+
def self.local_columns
|
|
513
|
+
if ephemeral?
|
|
514
|
+
raise Funicular::DB::NoTableError,
|
|
515
|
+
"#{to_s} is storage :ephemeral; it has no local table"
|
|
516
|
+
end
|
|
517
|
+
cols = @local_columns
|
|
518
|
+
return cols if cols
|
|
519
|
+
if local?
|
|
520
|
+
# The fold runs HERE, lazily, not at class-definition time: the
|
|
521
|
+
# docs promise migrate blocks are only recorded at class eval.
|
|
522
|
+
# Any instance path (new/create/find) lands here first, so the
|
|
523
|
+
# accessors exist before they can be called.
|
|
524
|
+
folded = Funicular::DB.fold_local_columns(self)
|
|
525
|
+
define_local_accessors(folded)
|
|
526
|
+
@local_columns = folded
|
|
527
|
+
else
|
|
528
|
+
@local_columns = derive_replica_columns
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def self.derive_replica_columns
|
|
533
|
+
sch = @schema
|
|
534
|
+
unless sch
|
|
535
|
+
raise Funicular::DB::UnavailableError,
|
|
536
|
+
"#{to_s}: no schema loaded; replica columns derive from the " \
|
|
537
|
+
"server schema at boot"
|
|
538
|
+
end
|
|
539
|
+
# @type var derived: Hash[String, Symbol]
|
|
540
|
+
derived = {}
|
|
541
|
+
names = sch.keys
|
|
542
|
+
i = 0
|
|
543
|
+
while i < names.size
|
|
544
|
+
attr_name = names[i]
|
|
545
|
+
type = (sch[attr_name]["type"] || "string").to_sym
|
|
546
|
+
derived[attr_name] = type unless type == :binary
|
|
547
|
+
i += 1
|
|
548
|
+
end
|
|
549
|
+
derived
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# The handle local queries run against: the guarded proxy for this
|
|
553
|
+
# model's storage role, installed by Funicular::DB.boot. Before the boot
|
|
554
|
+
# every materializer fails loud (UnavailableError -- SSR included).
|
|
555
|
+
# Storage-local models also encounter the SchemaTooNew lockdown here,
|
|
556
|
+
# the funnel every model-level local operation passes through.
|
|
557
|
+
def self.local_db
|
|
558
|
+
Funicular::DB.__model_local_db(self)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# The handle REST write-through applies replica rows to: the
|
|
562
|
+
# guarded replica proxy from DB.boot -- raw connections are never
|
|
563
|
+
# exposed globally. While it is nil (not booted), write-through
|
|
564
|
+
# stays inert and REST works standalone.
|
|
565
|
+
def self.replica_db
|
|
566
|
+
Funicular::DB.__model_replica_db
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Drop this table and rebuild it from its migrate baseline (docs
|
|
570
|
+
# decision 7): the programmatic reset for one client-only table.
|
|
571
|
+
# Writer (or volatile) tab only; lifts a SchemaTooNew lockdown
|
|
572
|
+
# when the whole declared set passes again afterwards.
|
|
573
|
+
def self.reset_local
|
|
574
|
+
unless local?
|
|
575
|
+
raise Funicular::DB::NoTableError,
|
|
576
|
+
"#{to_s} is not storage :local; reset_local rebuilds " \
|
|
577
|
+
"client-only tables"
|
|
578
|
+
end
|
|
579
|
+
Funicular::DB.reset_local_table(self)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def self.build_from_local(attrs)
|
|
583
|
+
record = new({})
|
|
584
|
+
record.__hydrate_local(attrs)
|
|
585
|
+
record
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Called by Relation#delete_all, the local CRUD writers, and the
|
|
589
|
+
# replica apply path after a framework-managed write: the change-
|
|
590
|
+
# event bus takes it from here (snapshot scheduling joins in at
|
|
591
|
+
# boot).
|
|
592
|
+
def self.local_table_changed
|
|
593
|
+
Funicular::DB.notify_changed(self)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# The public change-subscription primitive (docs decision 10):
|
|
597
|
+
# watch's Relation-only contract covers lists; for hashes, counts,
|
|
598
|
+
# or raw-SQL-derived state, subscribe here and patch state in the
|
|
599
|
+
# handler. Returns a subscription for off_change.
|
|
600
|
+
def self.on_change(&block)
|
|
601
|
+
unless block
|
|
602
|
+
raise ArgumentError, "on_change requires a block"
|
|
603
|
+
end
|
|
604
|
+
if ephemeral?
|
|
605
|
+
raise Funicular::DB::NoTableError,
|
|
606
|
+
"#{to_s} is storage :ephemeral; it has no local table to watch"
|
|
607
|
+
end
|
|
608
|
+
Funicular::DB.__ensure_local_database_enabled(:on_change)
|
|
609
|
+
Funicular::DB.subscribe(replica? ? :replica : :local,
|
|
610
|
+
table_name, &block)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def self.off_change(subscription)
|
|
614
|
+
Funicular::DB.unsubscribe(subscription)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# ---- write-through (docs decision 5) --------------------------------
|
|
618
|
+
# Successful REST responses mirror rows into the replica through the
|
|
619
|
+
# single apply entry point, BEFORE user callbacks run. Inert on
|
|
620
|
+
# non-replica models and until DB.boot installs the replica handle.
|
|
621
|
+
|
|
622
|
+
def self.__write_through_upsert(attrs)
|
|
623
|
+
return unless replica?
|
|
624
|
+
db = replica_db
|
|
625
|
+
return unless db
|
|
626
|
+
Funicular::DB.replica_upsert(db, self, attrs)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def self.__write_through_upsert_all(rows)
|
|
630
|
+
return unless replica?
|
|
631
|
+
db = replica_db
|
|
632
|
+
return unless db
|
|
633
|
+
Funicular::DB.replica_upsert_all(db, self, rows)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def self.__write_through_delete(id)
|
|
637
|
+
return unless replica?
|
|
638
|
+
db = replica_db
|
|
639
|
+
return unless db
|
|
640
|
+
Funicular::DB.replica_delete(db, self, id)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Generate attribute readers/writers from the migrate fold, mirroring
|
|
644
|
+
# what load_schema does from the REST schema. Methods the model class
|
|
645
|
+
# ALREADY defines (a hand-written reader like `def title`) are
|
|
646
|
+
# preserved: only the missing half of each accessor pair is
|
|
647
|
+
# generated. The snapshot is taken before anything is generated, so
|
|
648
|
+
# our own accessors never mask a later regeneration.
|
|
649
|
+
def self.define_local_accessors(columns)
|
|
650
|
+
existing = instance_methods
|
|
651
|
+
names = columns.keys
|
|
652
|
+
__assert_no_association_conflict(names)
|
|
653
|
+
names_size = names.size
|
|
654
|
+
i = 0
|
|
655
|
+
while i < names_size
|
|
656
|
+
name = names[i]
|
|
657
|
+
# One method call per column: the writer's closure must capture
|
|
658
|
+
# its own `name` binding. Creating it inside this while body
|
|
659
|
+
# would share ONE variable across every writer (unlike an each
|
|
660
|
+
# block parameter), leaving all of them bound to the last column.
|
|
661
|
+
define_local_accessor(name, existing) unless name == "id"
|
|
662
|
+
i += 1
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Writers track dirtiness against @local_baseline -- the values as
|
|
667
|
+
# last persisted/loaded -- not against the previous assignment, so
|
|
668
|
+
# assigning a value BACK cancels the change and the documented
|
|
669
|
+
# "update with no actual changes is a no-op" holds across
|
|
670
|
+
# intermediate assignments. A hand-written writer is kept and
|
|
671
|
+
# WRAPPED: it runs first, then the resulting ivar (its normalized
|
|
672
|
+
# value) feeds the same tracking -- otherwise custom writers would
|
|
673
|
+
# silently opt out of dirty tracking and update would no-op.
|
|
674
|
+
def self.define_local_accessor(name, existing)
|
|
675
|
+
attr_reader name.to_sym unless existing.include?(name.to_sym)
|
|
676
|
+
custom = "__custom_#{name}="
|
|
677
|
+
return if existing.include?(custom.to_sym)
|
|
678
|
+
if existing.include?("#{name}=".to_sym)
|
|
679
|
+
alias_method custom, "#{name}="
|
|
680
|
+
define_method("#{name}=") do |value|
|
|
681
|
+
# @type self: Model
|
|
682
|
+
old = instance_variable_get("@#{name}")
|
|
683
|
+
send(custom, value)
|
|
684
|
+
__track_local_change(name, instance_variable_get("@#{name}"), old)
|
|
685
|
+
end
|
|
686
|
+
else
|
|
687
|
+
define_method("#{name}=") do |value|
|
|
688
|
+
# @type self: Model
|
|
689
|
+
old = instance_variable_get("@#{name}")
|
|
690
|
+
instance_variable_set("@#{name}", value)
|
|
691
|
+
__track_local_change(name, value, old)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Merge bare keywords over the positional attrs hash so a keyword
|
|
697
|
+
# wins per ATTRIBUTE, not per literal key: initialize reads string
|
|
698
|
+
# keys first, so the keyword's string-keyed twin must go away too.
|
|
699
|
+
def self.merge_keyword_attrs(attrs, kw)
|
|
700
|
+
return attrs if kw.empty?
|
|
701
|
+
merged = attrs.merge(kw)
|
|
702
|
+
keys = kw.keys
|
|
703
|
+
keys_size = keys.size
|
|
704
|
+
i = 0
|
|
705
|
+
while i < keys_size
|
|
706
|
+
merged.delete(keys[i].to_s)
|
|
707
|
+
i += 1
|
|
708
|
+
end
|
|
709
|
+
merged
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Synchronous, validated create for storage :local (docs, "Local
|
|
713
|
+
# models"): returns the instance -- persisted with its assigned id,
|
|
714
|
+
# or unsaved (id nil) with errors when validation fails.
|
|
715
|
+
def self.local_create(attrs = {})
|
|
716
|
+
unless local?
|
|
717
|
+
if replica?
|
|
718
|
+
raise Funicular::DB::ReplicaWriteError,
|
|
719
|
+
"#{to_s}.local_create is not available on replica models; " \
|
|
720
|
+
"the server owns replica rows (use #{to_s}.create)"
|
|
721
|
+
end
|
|
722
|
+
raise Funicular::DB::NoTableError,
|
|
723
|
+
"#{to_s} has no local table; local_create is available only on " \
|
|
724
|
+
"storage :local models"
|
|
725
|
+
end
|
|
726
|
+
record = new(attrs)
|
|
727
|
+
return record unless record.valid?
|
|
728
|
+
record.__local_insert
|
|
729
|
+
record
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# ---- bare-class alias (source-of-truth contract) ---------------------
|
|
733
|
+
# On storage :local models the bare class IS the local view, so the
|
|
734
|
+
# ActiveRecord-style query methods hang off it directly. On other
|
|
735
|
+
# storage kinds they do not exist -- the error points at `.local`.
|
|
736
|
+
|
|
737
|
+
def self.local_query(method_name)
|
|
738
|
+
unless local?
|
|
739
|
+
raise NoMethodError,
|
|
740
|
+
"#{to_s}.#{method_name} only exists on storage :local models; " \
|
|
741
|
+
"the marked local view is #{to_s}.local.#{method_name}"
|
|
742
|
+
end
|
|
743
|
+
local
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def self.where(conditions = nil, *binds)
|
|
747
|
+
local_query("where").where(conditions, *binds)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def self.order(*args)
|
|
751
|
+
local_query("order").order(*args)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def self.limit(n)
|
|
755
|
+
local_query("limit").limit(n)
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def self.offset(n)
|
|
759
|
+
local_query("offset").offset(n)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def self.count
|
|
763
|
+
local_query("count").count
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def self.first
|
|
767
|
+
local_query("first").first
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def self.exists?
|
|
771
|
+
local_query("exists?").exists?
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def self.find_by(conditions)
|
|
775
|
+
local_query("find_by").find_by(conditions)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def self.delete_all
|
|
779
|
+
local_query("delete_all").delete_all
|
|
780
|
+
end
|
|
781
|
+
|
|
65
782
|
def initialize(attributes = {})
|
|
66
783
|
@changed_attributes = {}
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
784
|
+
# Attribute names come from the REST schema, or from the migrate
|
|
785
|
+
# fold on storage :local models (which have no REST schema).
|
|
786
|
+
# Key-presence lookups, not `||`: a string-keyed false (boolean
|
|
787
|
+
# columns) must not fall through to the symbol key and come back
|
|
788
|
+
# nil.
|
|
789
|
+
klass = self.class
|
|
790
|
+
is_local = klass.local?
|
|
791
|
+
names = is_local ? klass.local_columns.keys : klass.schema.keys
|
|
792
|
+
# Which attributes were EXPLICITLY given (even as nil) is recorded:
|
|
793
|
+
# the local INSERT path must distinguish "omitted, apply the SQL
|
|
794
|
+
# DEFAULT" from "explicit nil, bind NULL".
|
|
795
|
+
# @type var provided: Hash[String, bool]
|
|
796
|
+
provided = {}
|
|
797
|
+
names_size = names.size
|
|
798
|
+
i = 0
|
|
799
|
+
while i < names_size
|
|
800
|
+
name = names[i]
|
|
801
|
+
sym = name.to_sym
|
|
802
|
+
if attributes.has_key?(name)
|
|
803
|
+
value = attributes[name]
|
|
804
|
+
provided[name] = true
|
|
805
|
+
elsif attributes.has_key?(sym)
|
|
806
|
+
value = attributes[sym]
|
|
807
|
+
provided[name] = true
|
|
808
|
+
else
|
|
809
|
+
value = nil
|
|
810
|
+
end
|
|
811
|
+
if is_local && provided[name] && !(name == "id")
|
|
812
|
+
# User input goes through the writer, so a hand-written
|
|
813
|
+
# normalizing writer applies on create exactly as on update.
|
|
814
|
+
# Hydration from the database does NOT come through here (see
|
|
815
|
+
# build_from_local / __hydrate_local): stored values are
|
|
816
|
+
# already normalized.
|
|
817
|
+
send("#{name}=", value)
|
|
818
|
+
else
|
|
819
|
+
unless is_local || value.nil?
|
|
820
|
+
# REST values pass through the shared codec, so JSON strings
|
|
821
|
+
# and 1/0 become the same Ruby types local queries return
|
|
822
|
+
# (docs decision 8: Post.all and Post.local.find agree).
|
|
823
|
+
value = Funicular::DB::Codec.decode(
|
|
824
|
+
klass.rest_attribute_type(name), value)
|
|
825
|
+
end
|
|
826
|
+
instance_variable_set("@#{name}", value)
|
|
827
|
+
end
|
|
828
|
+
i += 1
|
|
829
|
+
end
|
|
830
|
+
@provided_attributes = provided
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# The schema-declared type of a REST attribute (nil when unknown);
|
|
834
|
+
# feeds the shared codec on the REST side.
|
|
835
|
+
def self.rest_attribute_type(name)
|
|
836
|
+
sch = @schema
|
|
837
|
+
return nil unless sch
|
|
838
|
+
config = sch[name]
|
|
839
|
+
return nil unless config
|
|
840
|
+
(config["type"] || "string").to_sym
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# A record not yet in the local table (docs: "a new record is one
|
|
844
|
+
# whose id is nil; create assigns the id from the inserted row").
|
|
845
|
+
def new_record?
|
|
846
|
+
@id.nil?
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# Shared dirty tracking behind every generated/wrapped local writer.
|
|
850
|
+
def __track_local_change(name, value, old)
|
|
851
|
+
@changed_attributes ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
852
|
+
baseline = @local_baseline
|
|
853
|
+
if baseline && baseline.has_key?(name)
|
|
854
|
+
if value == baseline[name]
|
|
855
|
+
@changed_attributes.delete(name)
|
|
856
|
+
else
|
|
857
|
+
@changed_attributes[name] = value
|
|
858
|
+
end
|
|
859
|
+
elsif !(value == old)
|
|
860
|
+
# No baseline yet (a hand-built record): previous-value compare.
|
|
861
|
+
@changed_attributes[name] = value
|
|
71
862
|
end
|
|
72
863
|
end
|
|
73
864
|
|
|
74
865
|
def self.all(params = {}, &block)
|
|
866
|
+
if local?
|
|
867
|
+
# The bare class is an alias for .local: Draft.all IS the
|
|
868
|
+
# whole-table Relation, and there is no REST side to call.
|
|
869
|
+
if block
|
|
870
|
+
raise ArgumentError,
|
|
871
|
+
"#{to_s}.all takes no block on storage :local (no REST side)"
|
|
872
|
+
end
|
|
873
|
+
unless params.nil? || params.empty?
|
|
874
|
+
raise ArgumentError,
|
|
875
|
+
"#{to_s}.all takes no params on storage :local (no REST side)"
|
|
876
|
+
end
|
|
877
|
+
return local
|
|
878
|
+
end
|
|
75
879
|
endpoint = @endpoints["all"]
|
|
76
880
|
return unless endpoint
|
|
77
881
|
|
|
78
|
-
|
|
79
|
-
|
|
882
|
+
path = endpoint["path"]
|
|
883
|
+
if params && !params.empty?
|
|
884
|
+
path = "#{path}?#{URI.encode_www_form(params)}"
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# A wipe between issue and response makes the response stale: it
|
|
888
|
+
# is discarded, never applied -- a logout can never resurrect the
|
|
889
|
+
# previous session's rows (docs decision 17).
|
|
890
|
+
generation = Funicular::DB.mutation_generation
|
|
891
|
+
HTTP.get(path) do |response|
|
|
892
|
+
if Funicular::DB.stale_generation?(generation)
|
|
893
|
+
block.call(nil, Funicular::DB.stale_response_error) if block
|
|
894
|
+
elsif response.error?
|
|
80
895
|
block.call(nil, response.error_message) if block
|
|
81
896
|
else
|
|
82
|
-
|
|
897
|
+
rows = response.data
|
|
898
|
+
rows_size = rows.size
|
|
899
|
+
# Fetch-through (docs decision 5): the whole collection lands
|
|
900
|
+
# in the replica -- one transaction, one change event -- before
|
|
901
|
+
# anything else sees it.
|
|
902
|
+
__write_through_upsert_all(rows)
|
|
903
|
+
# @type var instances: Array[Model]
|
|
904
|
+
instances = []
|
|
905
|
+
i = 0
|
|
906
|
+
while i < rows_size
|
|
907
|
+
instances << new(rows[i])
|
|
908
|
+
i += 1
|
|
909
|
+
end
|
|
83
910
|
block.call(instances, nil) if block
|
|
84
911
|
end
|
|
85
912
|
end
|
|
86
913
|
end
|
|
87
914
|
|
|
88
915
|
def self.find(id = nil, endpoint_name: "find", model_class: nil, &block)
|
|
916
|
+
if local?
|
|
917
|
+
if block
|
|
918
|
+
raise ArgumentError,
|
|
919
|
+
"#{to_s}.find is synchronous on storage :local and takes no block"
|
|
920
|
+
end
|
|
921
|
+
return local.find(id)
|
|
922
|
+
end
|
|
89
923
|
endpoint = @endpoints[endpoint_name]
|
|
90
924
|
return unless endpoint
|
|
91
925
|
|
|
92
926
|
path = endpoint["path"]
|
|
93
927
|
path = path.gsub(":id", id.to_s) if id
|
|
94
928
|
|
|
929
|
+
generation = Funicular::DB.mutation_generation
|
|
95
930
|
HTTP.get(path) do |response|
|
|
96
|
-
if
|
|
931
|
+
if Funicular::DB.stale_generation?(generation)
|
|
932
|
+
block.call(nil, Funicular::DB.stale_response_error) if block
|
|
933
|
+
elsif response.error?
|
|
97
934
|
block.call(nil, response.error_message) if block
|
|
98
935
|
else
|
|
99
936
|
klass = model_class || self
|
|
937
|
+
klass.__write_through_upsert(response.data)
|
|
100
938
|
instance = klass.new(response.data)
|
|
101
939
|
block.call(instance, nil) if block
|
|
102
940
|
end
|
|
103
941
|
end
|
|
104
942
|
end
|
|
105
943
|
|
|
106
|
-
|
|
944
|
+
# attrs may be a braced Hash (arrives positionally), bare keywords,
|
|
945
|
+
# or both -- keywords are merged in and win on the same key. The
|
|
946
|
+
# model_class: keyword is reserved for REST models only; on
|
|
947
|
+
# storage :local it is an ordinary attribute, so a column may be
|
|
948
|
+
# named model_class.
|
|
949
|
+
def self.create(attrs = {}, **kw, &block)
|
|
950
|
+
if local?
|
|
951
|
+
if block
|
|
952
|
+
raise ArgumentError,
|
|
953
|
+
"#{to_s}.create is synchronous on storage :local and takes no block"
|
|
954
|
+
end
|
|
955
|
+
return local_create(merge_keyword_attrs(attrs, kw))
|
|
956
|
+
end
|
|
957
|
+
model_class = kw.delete(:model_class)
|
|
958
|
+
attrs = merge_keyword_attrs(attrs, kw)
|
|
107
959
|
endpoint = @endpoints["create"]
|
|
108
960
|
return unless endpoint
|
|
109
961
|
|
|
@@ -114,11 +966,15 @@ module Funicular
|
|
|
114
966
|
return
|
|
115
967
|
end
|
|
116
968
|
|
|
969
|
+
generation = Funicular::DB.mutation_generation
|
|
117
970
|
HTTP.post(endpoint["path"], attrs) do |response|
|
|
118
|
-
if
|
|
971
|
+
if Funicular::DB.stale_generation?(generation)
|
|
972
|
+
block.call(nil, Funicular::DB.stale_response_error) if block
|
|
973
|
+
elsif response.error?
|
|
119
974
|
block.call(nil, response.error_message) if block
|
|
120
975
|
else
|
|
121
976
|
klass = model_class || self
|
|
977
|
+
klass.__write_through_upsert(response.data)
|
|
122
978
|
instance = klass.new(response.data)
|
|
123
979
|
block.call(instance, nil) if block
|
|
124
980
|
end
|
|
@@ -126,62 +982,118 @@ module Funicular
|
|
|
126
982
|
end
|
|
127
983
|
|
|
128
984
|
def self.destroy(id = nil, &block)
|
|
985
|
+
if local?
|
|
986
|
+
if block
|
|
987
|
+
raise ArgumentError,
|
|
988
|
+
"#{to_s}.destroy is synchronous on storage :local and takes no block"
|
|
989
|
+
end
|
|
990
|
+
return local.find(id).destroy
|
|
991
|
+
end
|
|
129
992
|
endpoint = @endpoints["destroy"]
|
|
130
993
|
return unless endpoint
|
|
131
994
|
|
|
132
995
|
path = id ? endpoint["path"].gsub(":id", id.to_s) : endpoint["path"]
|
|
133
996
|
|
|
997
|
+
generation = Funicular::DB.mutation_generation
|
|
134
998
|
HTTP.delete(path) do |response|
|
|
135
|
-
if
|
|
136
|
-
block.call(
|
|
999
|
+
if Funicular::DB.stale_generation?(generation)
|
|
1000
|
+
block.call(nil, Funicular::DB.stale_response_error) if block
|
|
1001
|
+
elsif response.error?
|
|
1002
|
+
block.call(nil, response.error_message) if block
|
|
137
1003
|
else
|
|
138
|
-
|
|
1004
|
+
__write_through_delete(id) unless id.nil?
|
|
1005
|
+
block.call(true, nil) if block
|
|
139
1006
|
end
|
|
140
1007
|
end
|
|
141
1008
|
end
|
|
142
1009
|
|
|
143
1010
|
def update(attrs = nil, &block)
|
|
1011
|
+
if self.class.local?
|
|
1012
|
+
if block
|
|
1013
|
+
raise ArgumentError,
|
|
1014
|
+
"update is synchronous on storage :local and takes no block"
|
|
1015
|
+
end
|
|
1016
|
+
return __local_update(attrs)
|
|
1017
|
+
end
|
|
144
1018
|
if attrs
|
|
145
1019
|
attrs.each { |k, v| send("#{k}=", v) }
|
|
146
1020
|
end
|
|
147
1021
|
|
|
148
1022
|
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
149
1023
|
unless valid?
|
|
150
|
-
block.call(
|
|
1024
|
+
block.call(nil, errors) if block
|
|
151
1025
|
return
|
|
152
1026
|
end
|
|
153
1027
|
|
|
154
|
-
return if @changed_attributes.empty?
|
|
155
|
-
|
|
156
1028
|
json_attrs = @changed_attributes.reject do |name, value|
|
|
157
1029
|
schema = self.class.schema[name]
|
|
158
1030
|
schema && schema["type"] == "binary"
|
|
159
1031
|
end
|
|
160
1032
|
|
|
161
|
-
|
|
1033
|
+
# Nothing to send (no changes, or binary-only changes that travel via
|
|
1034
|
+
# FileUpload): a successful no-op, reported like any success.
|
|
1035
|
+
if json_attrs.empty?
|
|
1036
|
+
block.call(self, nil) if block
|
|
1037
|
+
return
|
|
1038
|
+
end
|
|
162
1039
|
|
|
163
1040
|
endpoint = self.class.endpoints["update"]
|
|
164
1041
|
path = endpoint["path"].gsub(":id", @id.to_s)
|
|
165
1042
|
|
|
1043
|
+
generation = Funicular::DB.mutation_generation
|
|
166
1044
|
HTTP.patch(path, json_attrs) do |response|
|
|
167
|
-
if
|
|
168
|
-
block.call(
|
|
1045
|
+
if Funicular::DB.stale_generation?(generation)
|
|
1046
|
+
block.call(nil, Funicular::DB.stale_response_error) if block
|
|
1047
|
+
elsif response.error?
|
|
1048
|
+
block.call(nil, response.error_message) if block
|
|
169
1049
|
else
|
|
170
|
-
|
|
171
|
-
|
|
1050
|
+
data = response.data
|
|
1051
|
+
# The replica holds the server's row before the callback runs
|
|
1052
|
+
# (docs decision 4).
|
|
1053
|
+
self.class.__write_through_upsert(data)
|
|
1054
|
+
# Apply the server's authoritative row (defaults, callbacks and
|
|
1055
|
+
# normalizations included) through the codec before reporting
|
|
1056
|
+
# success.
|
|
1057
|
+
keys = data.keys
|
|
1058
|
+
keys_size = keys.size
|
|
1059
|
+
i = 0
|
|
1060
|
+
while i < keys_size
|
|
1061
|
+
key = keys[i]
|
|
1062
|
+
value = data[key]
|
|
1063
|
+
unless value.nil?
|
|
1064
|
+
value = Funicular::DB::Codec.decode(
|
|
1065
|
+
self.class.rest_attribute_type(key.to_s), value)
|
|
1066
|
+
end
|
|
172
1067
|
instance_variable_set("@#{key}", value)
|
|
1068
|
+
i += 1
|
|
173
1069
|
end
|
|
174
1070
|
@changed_attributes = {}
|
|
175
|
-
block.call(
|
|
1071
|
+
block.call(self, nil) if block
|
|
176
1072
|
end
|
|
177
1073
|
end
|
|
178
1074
|
end
|
|
179
1075
|
|
|
180
1076
|
def destroy(&block)
|
|
1077
|
+
if self.class.local?
|
|
1078
|
+
if block
|
|
1079
|
+
raise ArgumentError,
|
|
1080
|
+
"destroy is synchronous on storage :local and takes no block"
|
|
1081
|
+
end
|
|
1082
|
+
return __local_destroy
|
|
1083
|
+
end
|
|
181
1084
|
self.class.destroy(@id, &block)
|
|
182
1085
|
end
|
|
183
1086
|
|
|
184
1087
|
def reload(&block)
|
|
1088
|
+
if self.class.local?
|
|
1089
|
+
if block
|
|
1090
|
+
raise ArgumentError,
|
|
1091
|
+
"reload is synchronous on storage :local and takes no block"
|
|
1092
|
+
end
|
|
1093
|
+
__sync_from_row
|
|
1094
|
+
@changed_attributes = {}
|
|
1095
|
+
return self
|
|
1096
|
+
end
|
|
185
1097
|
self.class.find(@id) do |instance, error|
|
|
186
1098
|
if instance
|
|
187
1099
|
instance.instance_variables.each do |var|
|
|
@@ -192,5 +1104,248 @@ module Funicular
|
|
|
192
1104
|
block.call(instance, error) if block
|
|
193
1105
|
end
|
|
194
1106
|
end
|
|
1107
|
+
|
|
1108
|
+
# ---- storage :local write internals (framework use) ------------------
|
|
1109
|
+
# All three writers notify local_table_changed, the hook the change-
|
|
1110
|
+
# event bus and snapshot scheduling attach to at boot. SQLite
|
|
1111
|
+
# constraint violations escape as SQLite3::Exception on purpose: they
|
|
1112
|
+
# are bugs, not user-facing validation (docs, "Local models").
|
|
1113
|
+
|
|
1114
|
+
# INSERT this (validated) record. Columns that were never given (nil
|
|
1115
|
+
# and not explicitly provided) are left out so SQL DEFAULTs apply; an
|
|
1116
|
+
# EXPLICIT nil binds NULL and answers to NOT NULL like any write. The
|
|
1117
|
+
# row is read back afterwards, so the instance reflects what the
|
|
1118
|
+
# table actually stores.
|
|
1119
|
+
def __local_insert
|
|
1120
|
+
klass = self.class
|
|
1121
|
+
columns = klass.local_columns
|
|
1122
|
+
codec = Funicular::DB::Codec
|
|
1123
|
+
now = Time.now
|
|
1124
|
+
if columns.has_key?("created_at") && instance_variable_get("@created_at").nil?
|
|
1125
|
+
instance_variable_set("@created_at", now)
|
|
1126
|
+
end
|
|
1127
|
+
if columns.has_key?("updated_at") && instance_variable_get("@updated_at").nil?
|
|
1128
|
+
instance_variable_set("@updated_at", now)
|
|
1129
|
+
end
|
|
1130
|
+
names = columns.keys
|
|
1131
|
+
# @type var cols: Array[String]
|
|
1132
|
+
cols = []
|
|
1133
|
+
# @type var marks: Array[String]
|
|
1134
|
+
marks = []
|
|
1135
|
+
# @type var binds: Array[untyped]
|
|
1136
|
+
binds = []
|
|
1137
|
+
provided = @provided_attributes
|
|
1138
|
+
names_size = names.size
|
|
1139
|
+
i = 0
|
|
1140
|
+
while i < names_size
|
|
1141
|
+
name = names[i]
|
|
1142
|
+
unless name == "id"
|
|
1143
|
+
value = instance_variable_get("@#{name}")
|
|
1144
|
+
if !value.nil? || (provided && provided[name])
|
|
1145
|
+
cols << "\"#{name}\""
|
|
1146
|
+
marks << "?"
|
|
1147
|
+
binds << codec.encode(columns[name], value)
|
|
1148
|
+
end
|
|
1149
|
+
end
|
|
1150
|
+
i += 1
|
|
1151
|
+
end
|
|
1152
|
+
db = klass.local_db
|
|
1153
|
+
# RETURNING makes the INSERT itself hand back the id. A separate
|
|
1154
|
+
# SELECT last_insert_rowid() would read connection-global state:
|
|
1155
|
+
# another Task inserting into the same database between the two
|
|
1156
|
+
# statements would overwrite it, and this instance would adopt the
|
|
1157
|
+
# other Task's id (and then sync from the other Task's row).
|
|
1158
|
+
sql = if cols.empty?
|
|
1159
|
+
"INSERT INTO \"#{klass.table_name}\" DEFAULT VALUES RETURNING \"id\""
|
|
1160
|
+
else
|
|
1161
|
+
"INSERT INTO \"#{klass.table_name}\" (#{cols.join(", ")}) " \
|
|
1162
|
+
"VALUES (#{marks.join(", ")}) RETURNING \"id\""
|
|
1163
|
+
end
|
|
1164
|
+
row = db.execute(sql, binds)[0]
|
|
1165
|
+
@id = row.is_a?(Hash) ? row.values[0] : row[0]
|
|
1166
|
+
__sync_from_row
|
|
1167
|
+
@changed_attributes = {}
|
|
1168
|
+
klass.local_table_changed
|
|
1169
|
+
self
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# Validated, synchronous update: true/false. An update with no actual
|
|
1173
|
+
# changes is a no-op that returns true and does not touch updated_at.
|
|
1174
|
+
def __local_update(attrs)
|
|
1175
|
+
if attrs
|
|
1176
|
+
keys = attrs.keys
|
|
1177
|
+
keys_size = keys.size
|
|
1178
|
+
i = 0
|
|
1179
|
+
while i < keys_size
|
|
1180
|
+
send("#{keys[i]}=", attrs[keys[i]])
|
|
1181
|
+
i += 1
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
if new_record?
|
|
1185
|
+
raise ArgumentError,
|
|
1186
|
+
"cannot update a record that is not in the local table; use create"
|
|
1187
|
+
end
|
|
1188
|
+
return false unless valid?
|
|
1189
|
+
changed = @changed_attributes
|
|
1190
|
+
return true if changed.empty?
|
|
1191
|
+
klass = self.class
|
|
1192
|
+
columns = klass.local_columns
|
|
1193
|
+
codec = Funicular::DB::Codec
|
|
1194
|
+
# The updated_at stamp is a FRAMEWORK change: remember what it
|
|
1195
|
+
# replaced so a failed UPDATE can put it back -- otherwise a later,
|
|
1196
|
+
# genuinely change-free update would write (and notify) for the
|
|
1197
|
+
# leftover stamp alone. The USER's changes stay put on failure.
|
|
1198
|
+
stamped = false
|
|
1199
|
+
prev_stamp_ivar = nil
|
|
1200
|
+
prev_stamp_changed = nil
|
|
1201
|
+
had_stamp_changed = false
|
|
1202
|
+
# The rescue below must already be armed while the stamp is set and
|
|
1203
|
+
# the binds are encoded: Codec.encode raises ArgumentError on a
|
|
1204
|
+
# malformed datetime, and that failure has to revert the stamp too.
|
|
1205
|
+
begin
|
|
1206
|
+
if columns.has_key?("updated_at")
|
|
1207
|
+
stamped = true
|
|
1208
|
+
prev_stamp_ivar = instance_variable_get("@updated_at")
|
|
1209
|
+
had_stamp_changed = changed.has_key?("updated_at")
|
|
1210
|
+
prev_stamp_changed = changed["updated_at"]
|
|
1211
|
+
stamp = Time.now
|
|
1212
|
+
instance_variable_set("@updated_at", stamp)
|
|
1213
|
+
changed["updated_at"] = stamp
|
|
1214
|
+
end
|
|
1215
|
+
# @type var sets: Array[String]
|
|
1216
|
+
sets = []
|
|
1217
|
+
# @type var binds: Array[untyped]
|
|
1218
|
+
binds = []
|
|
1219
|
+
names = changed.keys
|
|
1220
|
+
names_size = names.size
|
|
1221
|
+
i = 0
|
|
1222
|
+
while i < names_size
|
|
1223
|
+
name = names[i]
|
|
1224
|
+
sets << "\"#{name}\" = ?"
|
|
1225
|
+
binds << codec.encode(columns[name], changed[name])
|
|
1226
|
+
i += 1
|
|
1227
|
+
end
|
|
1228
|
+
binds << @id
|
|
1229
|
+
# RETURNING answers "did the row exist?" inside the one UPDATE:
|
|
1230
|
+
# checking afterwards (even via the read-back below) would race a
|
|
1231
|
+
# concurrent delete + id-reusing re-create between the statements.
|
|
1232
|
+
updated = klass.local_db.execute(
|
|
1233
|
+
"UPDATE \"#{klass.table_name}\" SET #{sets.join(", ")} " \
|
|
1234
|
+
"WHERE \"id\" = ? RETURNING \"id\"", binds)
|
|
1235
|
+
if updated.empty?
|
|
1236
|
+
raise Funicular::RecordNotFound,
|
|
1237
|
+
"Couldn't find #{klass.to_s} with id=#{@id}"
|
|
1238
|
+
end
|
|
1239
|
+
# Re-read the row: the codec may have normalized what was written
|
|
1240
|
+
# (offset datetime strings fold into UTC, fractional seconds
|
|
1241
|
+
# truncate) and only the table knows the final values -- the
|
|
1242
|
+
# instance and the dirty-tracking baseline must reflect them, so a
|
|
1243
|
+
# re-fetch returns the same types this instance now carries.
|
|
1244
|
+
__sync_from_row
|
|
1245
|
+
rescue => e
|
|
1246
|
+
if stamped
|
|
1247
|
+
instance_variable_set("@updated_at", prev_stamp_ivar)
|
|
1248
|
+
if had_stamp_changed
|
|
1249
|
+
changed["updated_at"] = prev_stamp_changed
|
|
1250
|
+
else
|
|
1251
|
+
changed.delete("updated_at")
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
# Explicit re-raise: a bare `raise` would not re-raise on the
|
|
1255
|
+
# mruby VM.
|
|
1256
|
+
raise e
|
|
1257
|
+
end
|
|
1258
|
+
@changed_attributes = {}
|
|
1259
|
+
klass.local_table_changed
|
|
1260
|
+
true
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
def __local_destroy
|
|
1264
|
+
if new_record?
|
|
1265
|
+
raise ArgumentError,
|
|
1266
|
+
"cannot destroy a record that is not in the local table"
|
|
1267
|
+
end
|
|
1268
|
+
klass = self.class
|
|
1269
|
+
# RETURNING makes the DELETE itself report what it removed. A
|
|
1270
|
+
# separate SELECT changes() would read connection-global state that
|
|
1271
|
+
# another Task's write between the two statements overwrites --
|
|
1272
|
+
# dropping (or fabricating) the change notification.
|
|
1273
|
+
rows = klass.local_db.execute(
|
|
1274
|
+
"DELETE FROM \"#{klass.table_name}\" WHERE \"id\" = ? " \
|
|
1275
|
+
"RETURNING \"id\"", [@id])
|
|
1276
|
+
klass.local_table_changed unless rows.empty?
|
|
1277
|
+
true
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
# Re-read this record's row and decode it into the instance.
|
|
1281
|
+
def __sync_from_row
|
|
1282
|
+
klass = self.class
|
|
1283
|
+
columns = klass.local_columns
|
|
1284
|
+
codec = Funicular::DB::Codec
|
|
1285
|
+
names = columns.keys
|
|
1286
|
+
# @type var quoted: Array[String]
|
|
1287
|
+
quoted = []
|
|
1288
|
+
names_size = names.size
|
|
1289
|
+
i = 0
|
|
1290
|
+
while i < names_size
|
|
1291
|
+
quoted << "\"#{names[i]}\""
|
|
1292
|
+
i += 1
|
|
1293
|
+
end
|
|
1294
|
+
row = klass.local_db.execute(
|
|
1295
|
+
"SELECT #{quoted.join(", ")} FROM \"#{klass.table_name}\" " \
|
|
1296
|
+
"WHERE \"id\" = ?", [@id])[0]
|
|
1297
|
+
unless row
|
|
1298
|
+
# The row is gone (another instance's destroy, delete_all, ...):
|
|
1299
|
+
# fail loud instead of keeping stale attributes. On the update
|
|
1300
|
+
# path this fires BEFORE changes are cleared and before
|
|
1301
|
+
# local_table_changed, so a 0-row UPDATE neither discards the
|
|
1302
|
+
# pending changes nor notifies.
|
|
1303
|
+
raise Funicular::RecordNotFound,
|
|
1304
|
+
"Couldn't find #{klass.to_s} with id=#{@id}"
|
|
1305
|
+
end
|
|
1306
|
+
# @type var baseline: Hash[String, untyped]
|
|
1307
|
+
baseline = {}
|
|
1308
|
+
i = 0
|
|
1309
|
+
while i < names_size
|
|
1310
|
+
name = names[i]
|
|
1311
|
+
raw = row.is_a?(Hash) ? row[name] : row[i]
|
|
1312
|
+
decoded = codec.decode(columns[name], raw)
|
|
1313
|
+
instance_variable_set("@#{name}", decoded)
|
|
1314
|
+
baseline[name] = decoded
|
|
1315
|
+
i += 1
|
|
1316
|
+
end
|
|
1317
|
+
@local_baseline = baseline
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
# Hydrate this record from an already-decoded row: ivars are set
|
|
1321
|
+
# DIRECTLY (stored values are normalized; user writers must not run
|
|
1322
|
+
# again), the row becomes the dirty-tracking baseline, and nothing
|
|
1323
|
+
# is dirty.
|
|
1324
|
+
def __hydrate_local(attrs)
|
|
1325
|
+
keys = attrs.keys
|
|
1326
|
+
keys_size = keys.size
|
|
1327
|
+
i = 0
|
|
1328
|
+
while i < keys_size
|
|
1329
|
+
instance_variable_set("@#{keys[i]}", attrs[keys[i]])
|
|
1330
|
+
i += 1
|
|
1331
|
+
end
|
|
1332
|
+
__set_local_baseline(attrs)
|
|
1333
|
+
@changed_attributes = {}
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
# Install the dirty-tracking baseline (see define_local_accessor).
|
|
1337
|
+
# Framework use: called with the decoded row a record was built from.
|
|
1338
|
+
def __set_local_baseline(attrs)
|
|
1339
|
+
# @type var baseline: Hash[String, untyped]
|
|
1340
|
+
baseline = {}
|
|
1341
|
+
keys = attrs.keys
|
|
1342
|
+
keys_size = keys.size
|
|
1343
|
+
i = 0
|
|
1344
|
+
while i < keys_size
|
|
1345
|
+
baseline[keys[i].to_s] = attrs[keys[i]]
|
|
1346
|
+
i += 1
|
|
1347
|
+
end
|
|
1348
|
+
@local_baseline = baseline
|
|
1349
|
+
end
|
|
195
1350
|
end
|
|
196
1351
|
end
|