erupt-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "stringio"
5
+
6
+ module Erupt
7
+ # A small threaded HTTP/1.1 server (keep-alive, chunked request bodies) that runs a Rack application:
8
+ # enough for the server-to-server traffic of a node, so the SDK needs no web server gem. To serve a node
9
+ # from Puma, Rails or Sinatra, mount it there instead.
10
+ class HttpServer
11
+ MAX_BODY = 32 << 20
12
+ MAX_LINE = 16 << 10
13
+ MAX_HEADERS = 100
14
+ IDLE_SECONDS = 30
15
+ REASONS = { 100 => "Continue", 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found",
16
+ 413 => "Payload Too Large", 500 => "Internal Server Error" }.freeze
17
+
18
+ BadRequest = Class.new(StandardError)
19
+
20
+ def initialize(app, host, port)
21
+ @app, @port = app, port
22
+ @server = TCPServer.new(host, port)
23
+ end
24
+
25
+ # Accept until the server is closed (or the thread is interrupted); one thread per connection.
26
+ def run
27
+ loop do
28
+ client = @server.accept
29
+ Thread.new(client) { |c| connection(c) }
30
+ end
31
+ rescue IOError, Errno::EBADF
32
+ nil # closed
33
+ end
34
+
35
+ def close = @server.close
36
+
37
+ private
38
+
39
+ def connection(io)
40
+ loop do
41
+ env = read_request(io) or break
42
+ status, headers, body = @app.call(env)
43
+ keep_alive = keep_alive?(env)
44
+ head = +"HTTP/1.1 #{status} #{REASONS[status]}\r\n"
45
+ headers.each { |k, v| Array(v).each { |line| head << "#{k}: #{line}\r\n" } }
46
+ io.write(head << "connection: #{keep_alive ? "keep-alive" : "close"}\r\n\r\n")
47
+ body.each { |chunk| io.write(chunk) }
48
+ body.close if body.respond_to?(:close)
49
+ break unless keep_alive
50
+ end
51
+ rescue BadRequest => e
52
+ io.write("HTTP/1.1 #{e.message} #{REASONS[e.message.to_i]}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
53
+ rescue IOError, SystemCallError
54
+ nil # the peer went away
55
+ ensure
56
+ io.close
57
+ end
58
+
59
+ def keep_alive?(env)
60
+ connection = env["HTTP_CONNECTION"].to_s.downcase
61
+ env["SERVER_PROTOCOL"] == "HTTP/1.1" ? !connection.include?("close") : connection.include?("keep-alive")
62
+ end
63
+
64
+ def read_request(io)
65
+ return unless IO.select([io], nil, nil, IDLE_SECONDS)
66
+
67
+ line = io.gets("\r\n", MAX_LINE) or return
68
+ method, target, version = line.strip.split(" ", 3)
69
+ raise BadRequest, "400" unless target && version&.start_with?("HTTP/")
70
+
71
+ env = { "REQUEST_METHOD" => method, "SCRIPT_NAME" => "", "SERVER_PROTOCOL" => version,
72
+ "SERVER_NAME" => "localhost", "SERVER_PORT" => @port.to_s, "REMOTE_ADDR" => io.peeraddr.last,
73
+ "rack.url_scheme" => "http", "rack.errors" => $stderr }
74
+ env["PATH_INFO"], env["QUERY_STRING"] = target.split("?", 2)
75
+ env["QUERY_STRING"] ||= ""
76
+ read_headers(io, env)
77
+ io.write("HTTP/1.1 100 Continue\r\n\r\n") if env["HTTP_EXPECT"].to_s.casecmp?("100-continue")
78
+ env["rack.input"] = StringIO.new(read_body(io, env))
79
+ env
80
+ end
81
+
82
+ def read_headers(io, env)
83
+ MAX_HEADERS.times do
84
+ line = io.gets("\r\n", MAX_LINE) or raise BadRequest, "400"
85
+ return if line == "\r\n"
86
+
87
+ name, value = line.split(":", 2)
88
+ raise BadRequest, "400" unless value
89
+
90
+ key = name.strip.upcase.tr("-", "_")
91
+ key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
92
+ env[key] = env.key?(key) ? "#{env[key]}, #{value.strip}" : value.strip
93
+ end
94
+ raise BadRequest, "400"
95
+ end
96
+
97
+ def read_body(io, env)
98
+ if env["HTTP_TRANSFER_ENCODING"].to_s.downcase.include?("chunked")
99
+ body = +"".b
100
+ while (size = io.gets("\r\n", MAX_LINE).to_s.to_i(16)).positive?
101
+ raise BadRequest, "413" if body.bytesize + size > MAX_BODY
102
+
103
+ body << io.read(size).to_s
104
+ io.read(2)
105
+ end
106
+ nil while (trailer = io.gets("\r\n", MAX_LINE)) && trailer != "\r\n"
107
+ return body
108
+ end
109
+ length = Integer(env["CONTENT_LENGTH"] || "0", 10, exception: false) or raise BadRequest, "400"
110
+ raise BadRequest, "413" if length > MAX_BODY
111
+
112
+ length.positive? ? io.read(length).to_s : +"".b
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+
6
+ module Erupt
7
+ # Model declaration: classes that +extend Erupt::Model+, with +field+ and +operation+ declarations, compiled
8
+ # into erupt build JSON.
9
+ #
10
+ # Nothing here re-implements annotation semantics. Every field and operation starts from the template the
11
+ # Java proxies emit (defaults.json, generated by erupt-test NodeDefaultsTest); a declaration only overrides
12
+ # values on top of it.
13
+ DEFAULTS = JSON.parse(File.read(File.join(__dir__, "defaults.json"))).freeze
14
+
15
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
16
+ # Not an EditType: the SDK's DATE whose picker includes the time, inferred from a Time field
17
+ DATE_TIME = "DATE_TIME"
18
+ REFERENCES = [EditType::REFERENCE_TABLE, EditType::REFERENCE_TREE].freeze
19
+ # many records of another model, stored as the JSON list of their keys
20
+ COLLECTIONS = [EditType::CHECKBOX, EditType::TAB_TREE, EditType::TRANSFER].freeze
21
+ # the edit attribute that names a fetchHandler, by the type that fetches options
22
+ FETCHED = { EditType::CHOICE => "choiceType", EditType::MULTI_CHOICE => "multiChoiceType", EditType::TAGS => "tagsType" }.freeze
23
+ TIME_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?Z?\z/
24
+
25
+ # Ruby class of a field -> the edit type it infers (DateTime before its superclass Date)
26
+ KINDS = { String => EditType::INPUT, Numeric => EditType::NUMBER, Time => DATE_TIME, DateTime => DATE_TIME,
27
+ Date => EditType::DATE, TrueClass => EditType::BOOLEAN, boolean: EditType::BOOLEAN }.freeze
28
+
29
+ module_function
30
+
31
+ def deep_dup(value) = Marshal.load(Marshal.dump(value))
32
+
33
+ def merge!(target, override)
34
+ override.each do |key, value|
35
+ if value.is_a?(Hash) && target[key].is_a?(Hash)
36
+ merge!(target[key], value)
37
+ else
38
+ target[key] = deep_dup(value)
39
+ end
40
+ end
41
+ target
42
+ end
43
+
44
+ # Raw annotation attributes as JSON data: symbol keys and symbol values become strings.
45
+ def stringify(value)
46
+ case value
47
+ when Hash then value.to_h { |k, v| [k.to_s, stringify(v)] }
48
+ when Array then value.map { |v| stringify(v) }
49
+ when Symbol then value.to_s
50
+ else value
51
+ end
52
+ end
53
+
54
+ # A DATE_TIME as a local Time: "2026-01-05", "2026-01-05 10:30", "2026-01-05T10:30:00.123Z", Time, Date.
55
+ def parse_time(value)
56
+ case value
57
+ when Time then return value
58
+ when DateTime then return value.to_time
59
+ when Date then return Time.new(value.year, value.month, value.day)
60
+ end
61
+ m = TIME_PATTERN.match(value.to_s.strip)
62
+ y, mo, d, h, mi, s = m && m.captures.first(6).map(&:to_i)
63
+ raise ArgumentError, "illegal date '#{value}'" unless m && Date.valid_date?(y, mo, d) && h < 24 && mi < 60 && s < 60
64
+
65
+ Time.new(y, mo, d, h, mi, s + (m[7] ? Rational(m[7].to_i, 10**m[7].size) : 0))
66
+ end
67
+
68
+ # A number as an Integer when it is integral, a Float otherwise.
69
+ def number(value)
70
+ n = value.is_a?(String) ? Integer(value, 10, exception: false) || Float(value) : value
71
+ raise ArgumentError unless n.is_a?(Numeric) && n.finite?
72
+ return n if n.is_a?(Integer)
73
+
74
+ n == n.to_i ? n.to_i : n.to_f
75
+ rescue ArgumentError, TypeError
76
+ raise ArgumentError, "illegal number '#{value}'"
77
+ end
78
+
79
+ def json_value(value)
80
+ case value
81
+ when Time, DateTime then value.strftime("%Y-%m-%dT%H:%M:%S.%L")
82
+ when Date then value.strftime("%Y-%m-%d")
83
+ else value
84
+ end
85
+ end
86
+
87
+ # One column. +title+ labels both the table column and the form input; +view+ / +edit+ take raw annotation
88
+ # attributes (<tt>{ inputType: { length: 64 } }</tt>) for anything the shortcuts do not cover.
89
+ #
90
+ # A field whose kind is another model class references one of its records: the repository stores the
91
+ # referenced primary key, the UI shows +label+. +tree+ names the referenced model's parent field and turns
92
+ # the picker into a tree.
93
+ #
94
+ # A field of kind Array or Hash holds a JSON value (MULTI_CHOICE, KEY_VALUE): stored as JSON text by SQL
95
+ # repositories, served as JSON. +virtual: true+ declares a form-only element (DIVIDE, CALLOUT, EMPTY, GROUP)
96
+ # that is in the build but never read or written; so is a BUTTON.
97
+ #
98
+ # +fetch+, +onchange+, +complete+ and +click+ are the field's own code, run by the node when the server
99
+ # invokes it (see Node).
100
+ class Field
101
+ attr_reader :name, :title, :type, :choices, :label, :tree, :ref, :fetch, :onchange, :complete, :click
102
+
103
+ def initialize(name, title = "", kind = nil, type: nil, pk: false, search: false, not_null: false, sortable: false,
104
+ table: true, form: true, choices: nil, ref: nil, label: :name, tree: nil, view: {}, edit: {},
105
+ virtual: false, fetch: nil, onchange: nil, complete: nil, click: nil)
106
+ @json, @virtual = [Array, Hash].include?(kind), virtual || type.to_s == EditType::BUTTON
107
+ @fetch, @onchange, @complete, @click = fetch, onchange, complete, click
108
+ kind = String if @json
109
+ @name, @title, @pk, @search, @not_null = name.to_s, title, pk, search, not_null
110
+ @sortable, @table, @form = sortable, table, form
111
+ @choices = choices&.to_h { |k, v| [k.to_s, v.to_s] }
112
+ @ref = ref || (kind if kind.respond_to?(:erupt_model))
113
+ @label, @tree = label.to_s, tree&.to_s
114
+ @view, @edit = Erupt.stringify(view), Erupt.stringify(edit)
115
+ # referenceTableType.dependColumn is @Transient in Java: kept out of the schema check, written into the build
116
+ @depend_column = @edit["referenceTableType"].delete("dependColumn") if @edit["referenceTableType"].is_a?(Hash)
117
+ path = "field #{@name}"
118
+ raise ArgumentError, "#{path}: name must be an identifier" unless IDENTIFIER.match?(@name)
119
+
120
+ Schema.check("EditType", type.to_s, "#{path}.type") if type
121
+ Schema.check("View", @view, "#{path}.view")
122
+ Schema.check("Edit", @edit, "#{path}.edit")
123
+ @type = resolve_type(kind, type&.to_s, path) unless pk
124
+ raise ArgumentError, "#{path}: edit type #{@type} is not supported yet" if @type && !DEFAULTS["fields"].key?(@type)
125
+
126
+ check_reference(path) if reference? || collection?
127
+ if (@fetch && !FETCHED.key?(@type)) || (@complete && @type != EditType::AUTO_COMPLETE) || (@click && @type != EditType::BUTTON)
128
+ raise ArgumentError, "#{path}: fetch, complete and click belong to CHOICE / MULTI_CHOICE / TAGS, AUTO_COMPLETE and BUTTON"
129
+ end
130
+
131
+ @json ||= collection?
132
+ end
133
+
134
+ %i[pk search not_null sortable table form json virtual].each { |flag| define_method(:"#{flag}?") { instance_variable_get(:"@#{flag}") } }
135
+
136
+ def reference? = REFERENCES.include?(@type)
137
+
138
+ def collection? = COLLECTIONS.include?(@type)
139
+
140
+ # The referenced model, for a reference field.
141
+ def target = @ref.erupt_model
142
+
143
+ def build
144
+ tpl = Erupt.deep_dup(DEFAULTS["fields"][pk? ? "id" : @type])
145
+ tpl["fieldName"] = @name
146
+ tpl["componentValue"] = nil
147
+ return tpl if pk?
148
+
149
+ edit = tpl["eruptFieldJson"]["edit"]
150
+ # a BUTTON is a form element only: its template has no table column
151
+ view = tpl["eruptFieldJson"]["views"][0] || {}
152
+ edit.merge!("title" => @title, "notNull" => not_null?, "show" => form?)
153
+ edit["search"]["value"] = search?
154
+ view.merge!("title" => @title, "sortable" => sortable?)
155
+ view["type"] = "DATE" if @type == EditType::DATE
156
+ if reference?
157
+ tpl["fieldReturnName"] = target.name
158
+ picker = edit[@type == EditType::REFERENCE_TABLE ? "referenceTableType" : "referenceTreeType"]
159
+ picker.merge!("id" => target.pk, "label" => @label)
160
+ picker["pid"] = @tree if @tree
161
+ view["column"] = @label
162
+ elsif collection?
163
+ # the server lists a CHECKBOX by the view column, a TAB_TREE by the target's own tree
164
+ tpl["fieldReturnName"] = target.name
165
+ view["column"] = @label
166
+ end
167
+ edit[FETCHED[@type]]["fetchHandler"] = ["invoke"] if @fetch
168
+ edit["onchange"] = "invoke" if @onchange
169
+ Erupt.merge!(edit, @edit)
170
+ edit["referenceTableType"]["dependColumn"] = @depend_column if @depend_column
171
+ Erupt.merge!(view, @view)
172
+ tpl["eruptFieldJson"]["views"] = [] unless table?
173
+ if @choices # CHOICE, or MULTI_CHOICE over an Array
174
+ tpl["componentValue"] = @choices.to_a.map do |value, label|
175
+ { "value" => value, "label" => label, "desc" => nil, "color" => nil, "disable" => false }
176
+ end
177
+ end
178
+ tpl
179
+ end
180
+
181
+ # Turn a JSON value from the browser into the Ruby value stored by the repository.
182
+ def coerce(value)
183
+ if reference?
184
+ # the browser sends the picked record as {<id>: ..., <label>: ...}
185
+ value = value[target.pk] if value.is_a?(Hash)
186
+ return target.fields[target.pk].coerce(value)
187
+ end
188
+ if collection?
189
+ # picked records arrive as [{"id": key}] (CHECKBOX) or [{<pk>: key}] (TAB_TREE), and are stored as keys
190
+ items = value.is_a?(String) ? JSON.parse(value.empty? ? "[]" : value) : value || []
191
+ raise ArgumentError, "must be a JSON array" unless items.is_a?(Array)
192
+
193
+ return items.map { |v| target.fields[target.pk].coerce(v.is_a?(Hash) ? v.values.first : v) }
194
+ end
195
+ return nil if value.nil? || value == ""
196
+ return integer(value) if pk?
197
+ return json(value) if json?
198
+
199
+ case @type
200
+ when EditType::BOOLEAN then [true, false].include?(value) ? value : %w[true 1].include?(value.to_s.downcase)
201
+ when EditType::NUMBER, EditType::RATE, EditType::SLIDER then Erupt.number(value)
202
+ when DATE_TIME then Erupt.parse_time(value)
203
+ when EditType::DATE then Erupt.parse_time(value).to_date
204
+ when EditType::CHOICE
205
+ raise ArgumentError, "illegal option '#{value}'" if @choices && !@choices.key?(value.to_s)
206
+
207
+ value.to_s
208
+ else value.to_s
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ def json(value)
215
+ value = JSON.parse(value) if value.is_a?(String)
216
+ raise ArgumentError, "must be a JSON array or object" unless value.is_a?(Array) || value.is_a?(Hash)
217
+ raise ArgumentError, "illegal option in #{value}" if @choices && value.is_a?(Array) && !(value.map(&:to_s) - @choices.keys).empty?
218
+
219
+ value
220
+ end
221
+
222
+ def integer(value)
223
+ value.is_a?(String) ? Integer(value.strip, 10) : Integer(value)
224
+ rescue ArgumentError, TypeError
225
+ raise ArgumentError, "illegal id '#{value}'"
226
+ end
227
+
228
+ def resolve_type(kind, type, path)
229
+ return type if COLLECTIONS.include?(type)
230
+ return @tree ? EditType::REFERENCE_TREE : EditType::REFERENCE_TABLE if @ref
231
+ return type if type
232
+ return @json ? EditType::MULTI_CHOICE : EditType::CHOICE if @choices || @fetch
233
+ return EditType::INPUT if kind.nil?
234
+
235
+ KINDS.find { |k, _| k == kind || (k.is_a?(Class) && kind.is_a?(Class) && kind <= k) }&.last ||
236
+ raise(ArgumentError, "#{path}: cannot infer an edit type from #{kind.inspect}; pass type:")
237
+ end
238
+
239
+ def check_reference(path)
240
+ raise ArgumentError, "#{path}: ref must be a model class declared before this one" unless @ref.respond_to?(:erupt_model)
241
+ raise ArgumentError, "#{path}: #{target.name} has no primary key" if target.pk.nil?
242
+
243
+ [@label, @tree].compact.each do |key|
244
+ raise ArgumentError, "#{path}: #{target.name} has no field '#{key}'" unless target.fields.key?(key)
245
+ end
246
+ end
247
+ end
248
+
249
+ # What an operation block receives: +rows+ (selected records as the repository returned them; empty for
250
+ # BUTTON mode), +param+ (coerced form values, or nil without a form), +repo+, +model+, +user+.
251
+ OperationContext = Struct.new(:rows, :param, :repo, :model, :user, keyword_init: true)
252
+
253
+ # A row operation declared with +operation+.
254
+ class Operation
255
+ attr_reader :code, :title, :mode, :form, :handler
256
+
257
+ def initialize(code, title, mode: RowOperationMode::MULTI, icon: nil, tip: "", confirm: true, form: nil, show: nil,
258
+ if_expr: "", **raw, &handler)
259
+ @code, @title, @mode, @icon, @tip = code.to_s, title, mode.to_s, icon, tip
260
+ @confirm, @form, @show, @if_expr, @raw = confirm, form, show, if_expr, Erupt.stringify(raw)
261
+ @handler = handler or raise ArgumentError, "operation #{@code}: a block is required"
262
+ Schema.check("RowOperationMode", @mode, "operation #{@code}.mode")
263
+ Schema.check("RowOperation", @raw, "operation #{@code}")
264
+ raise ArgumentError, "operation #{@code}: form must be a model class" if form && !form.respond_to?(:erupt_model)
265
+ end
266
+
267
+ def form_model = @form&.erupt_model
268
+
269
+ def visible?(user) = @show.nil? || !!@show.call(user)
270
+
271
+ def build
272
+ tpl = Erupt.deep_dup(DEFAULTS["rowOperation"])
273
+ tpl.merge!("code" => @code, "title" => @title, "mode" => @mode, "tip" => @tip, "ifExpr" => @if_expr,
274
+ "type" => "ERUPT", "eruptClass" => form_model ? form_model.name : "void")
275
+ tpl["icon"] = @icon if @icon
276
+ if @confirm == false
277
+ tpl["callHint"] = "" # the browser runs it without asking
278
+ elsif @confirm.is_a?(String)
279
+ tpl["callHint"] = @confirm
280
+ end
281
+ Erupt.merge!(tpl, @raw)
282
+ end
283
+ end
284
+
285
+ # A drill: +erupt+ the linked model, +column+ the source row's column, +join+ the linked model's field holding
286
+ # it, +condition+ the linkCondition (SQL as written).
287
+ Drill = Struct.new(:erupt, :column, :join, :condition)
288
+
289
+ # A business error shown to the user as a message: raise it from a DataProxy hook or an operation.
290
+ class Fail < StandardError; end
291
+
292
+ # Hooks around a model's data, as erupt's Java +DataProxy+: subclass it, override what you need and pass it
293
+ # as <tt>erupt ..., data_proxy: ...</tt>. A record is the Hash the repository stores, a row the Hash the
294
+ # browser reads; change them in place. Raise Fail to refuse with a message.
295
+ class DataProxy
296
+ # Default values of the search bar, <tt>condition[field] = value</tt>.
297
+ def search_condition(condition) = nil
298
+ # SQL ANDed to a table page's WHERE, as written: <tt>"status <> 'deleted'"</tt>, or with its values bound,
299
+ # <tt>["owner = ?", [uid]]</tt>. +conditions+ are the search bar's. Only SQL repositories apply it.
300
+ def before_fetch(conditions) = nil
301
+ # The rows of a table page, before they are sent.
302
+ def after_fetch(rows) = nil
303
+ # The values a new record's form opens with.
304
+ def add_behavior(record) = nil
305
+ # A record read by its key, for the edit form or the detail view.
306
+ def edit_behavior(record) = nil
307
+ # A record about to be added or updated.
308
+ def validate(record) = nil
309
+ def before_add(record) = nil
310
+ def after_add(record) = nil
311
+ def before_update(record) = nil
312
+ def after_update(record) = nil
313
+ def before_delete(record) = nil
314
+ def after_delete(record) = nil
315
+ end
316
+
317
+ # What +extend Erupt::Model+ declares about a class.
318
+ class ModelSpec
319
+ # fields: stored and served; declared: all of them in order, virtual ones included, for the build
320
+ attr_reader :cls, :name, :title, :power, :fields, :declared, :operations, :proxies
321
+ # drill code -> Drill, whose column and joinColumn stay here (@Transient in Java)
322
+ attr_reader :drills
323
+
324
+ def initialize(cls)
325
+ @cls, @name = cls, cls.name&.split("::")&.last
326
+ @title, @power, @raw, @order_by, @views = @name, DEFAULTS["power"].dup, {}, nil, {}
327
+ @fields, @declared, @operations, @proxies, @drills = {}, [], {}, [], {}
328
+ end
329
+
330
+ def configure(title, name, power, order_by, data_proxy, raw)
331
+ @title, @name, @order_by, @order, @vis = title || @title, (name || @name).to_s, order_by, nil, nil
332
+ @proxies = Array(data_proxy).map { |p| p.is_a?(Class) ? p.new : p }
333
+ raise ArgumentError, "#{@name}: data_proxy must be a DataProxy" unless @proxies.all?(DataProxy)
334
+ @power = DEFAULTS["power"].merge(Erupt.stringify(power || {}))
335
+ @raw, @views = pull_vis(Erupt.stringify(raw))
336
+ @raw, @drills = pull_drills(@raw)
337
+ Schema.check("Erupt", @raw, "erupt #{@name}")
338
+ raise ArgumentError, "erupt name '#{@name}' must be an identifier" unless IDENTIFIER.match?(@name)
339
+ end
340
+
341
+ def add_field(field)
342
+ raise ArgumentError, "#{@name}: field '#{field.name}' declared twice" if @declared.any? { |f| f.name == field.name }
343
+ raise ArgumentError, "#{@name}: at most one primary key" if field.pk? && pk
344
+
345
+ @declared << field
346
+ @fields[field.name] = field unless field.virtual?
347
+ end
348
+
349
+ def add_operation(op)
350
+ raise ArgumentError, "#{@name}: operation code '#{op.code}' declared twice" if @operations.key?(op.code)
351
+
352
+ @operations[op.code] = op
353
+ end
354
+
355
+ # The primary key field's name; a model without one can only serve as an operation form.
356
+ def pk = @fields.each_value.find(&:pk?)&.name
357
+
358
+ # The default table order: "views desc, id" -> [["views", true], ["id", false]], checked against the fields.
359
+ def order = @order ||= parse_order(@order_by)
360
+
361
+ # View code -> [filters, order] the node applies to that view's table queries.
362
+ def vis = @vis ||= @views.transform_values { |filters, order_by| [filters.map { |f| [f, []] }, parse_order(order_by)] }
363
+
364
+ # [field, dependNode] of a @LinkTree: the reference the tree beside the table filters.
365
+ def link_tree
366
+ tree = @raw["linkTree"] || {}
367
+ return unless tree["field"]
368
+ raise ArgumentError, "#{@name}: linkTree.field '#{tree["field"]}' must be a reference field" unless @fields[tree["field"]]&.reference?
369
+
370
+ [tree["field"], tree["dependNode"] == true]
371
+ end
372
+
373
+ # Run one DataProxy hook on every proxy, in declaration order.
374
+ def hook(name, *args) = @proxies.each { |p| p.public_send(name, *args) }
375
+
376
+ # The SQL every proxy's +before_fetch+ adds, as <tt>[[sql, params], ...]</tt>.
377
+ def filters(conditions)
378
+ @proxies.filter_map do |p|
379
+ sql = p.before_fetch(conditions)
380
+ sql.is_a?(String) ? [sql, []] : [sql[0], Array(sql[1])] if sql && !sql.empty?
381
+ end
382
+ end
383
+
384
+ # Checks what can only be checked once every field is declared; the node calls it on register.
385
+ def validate!
386
+ raise ArgumentError, "erupt name '#{@name}' must be an identifier" unless IDENTIFIER.match?(@name.to_s)
387
+
388
+ order
389
+ vis
390
+ link_tree
391
+ self
392
+ end
393
+
394
+ def build_erupt(operations = [])
395
+ erupt = Erupt.merge!(Erupt.deep_dup(DEFAULTS["erupt"]), "primaryKeyCol" => pk || "id")
396
+ erupt["rowOperation"] = operations.map(&:build)
397
+ Erupt.merge!(erupt, @raw)
398
+ { "eruptName" => @name, "eruptJson" => erupt, "eruptFieldModels" => @declared.map(&:build),
399
+ "searchCondition" => {}.tap { |c| hook(:search_condition, c) }, "extraRow" => false, "tags" => {} }
400
+ end
401
+
402
+ def build_model(power, user = nil)
403
+ operations = @operations.values.select { |op| op.visible?(user) }
404
+ forms = operations.select(&:form).to_h { |op| [op.code, op.form_model.build_erupt] }
405
+ { "eruptModel" => build_erupt(operations), "power" => power, "tabErupts" => nil, "combineErupts" => nil,
406
+ "operationErupts" => forms.empty? ? nil : forms }
407
+ end
408
+
409
+ private
410
+
411
+ def parse_order(order_by)
412
+ order_by.to_s.split(",").map(&:strip).reject(&:empty?).map do |part|
413
+ field, dir, extra = part.split
414
+ unless @fields.key?(field) && extra.nil? && (dir.nil? || %w[asc desc].include?(dir.downcase))
415
+ raise ArgumentError, "#{@name}: illegal order_by '#{part}'"
416
+ end
417
+
418
+ [field, dir&.downcase == "desc"]
419
+ end
420
+ end
421
+
422
+ # +filter+ and +orderBy+ of a +vis+ entry run on the node, as Java keeps them off the build (@Transient):
423
+ # taken out before the schema check. A view without a +code+ is addressed by its title.
424
+ def pull_vis(raw)
425
+ return [raw, {}] unless raw["vis"].is_a?(Array)
426
+
427
+ views = {}
428
+ entries = raw["vis"].map do |entry|
429
+ next entry unless entry.is_a?(Hash)
430
+
431
+ entry = { "code" => entry["title"].to_s }.merge(entry)
432
+ views[entry["code"]] = [Array(entry.delete("filter")), entry.delete("orderBy")]
433
+ entry
434
+ end
435
+ [raw.merge("vis" => entries), views]
436
+ end
437
+
438
+ # +link.column+ / +link.joinColumn+ of a drill: the source row's column (+id+ by default) and the linked
439
+ # model's field holding it, applied by the node and kept out of the build. A drill without a +code+ is
440
+ # addressed by its title.
441
+ def pull_drills(raw)
442
+ return [raw, {}] unless raw["drills"].is_a?(Array)
443
+
444
+ drills = {}
445
+ entries = raw["drills"].map do |entry|
446
+ next entry unless entry.is_a?(Hash)
447
+
448
+ link = entry["link"].is_a?(Hash) ? entry["link"].dup : {}
449
+ column, join = link.delete("column") || "id", link.delete("joinColumn")
450
+ raise ArgumentError, "#{@name}: drill '#{entry["title"]}' needs link.linkErupt and link.joinColumn" unless join && link["linkErupt"]
451
+
452
+ entry = Schema.defaults("Drill").merge("code" => entry["title"].to_s).merge(entry)
453
+ entry["link"] = Schema.defaults("Link").merge(link)
454
+ drills[entry["code"]] = Drill.new(link["linkErupt"], column, join.to_s.split(".").first, link["linkCondition"].to_s)
455
+ entry
456
+ end
457
+ [raw.merge("drills" => entries), drills]
458
+ end
459
+ end
460
+
461
+ # The declaration DSL:
462
+ #
463
+ # class Article
464
+ # extend Erupt::Model
465
+ # erupt "Article", order_by: "id desc"
466
+ # field :id, pk: true
467
+ # field :title, "Title", search: true, not_null: true
468
+ # operation :publish, "Publish" do |op| ... end
469
+ # end
470
+ module Model
471
+ def erupt_model = @erupt_model ||= ModelSpec.new(self)
472
+
473
+ # +name+ defaults to the class name and is what the server addresses (<tt>node.Name</tt>); +power+
474
+ # switches features off (<tt>{ delete: false }</tt>); +order_by+ is the default table order, applied by
475
+ # the node (<tt>"views desc, id"</tt>); +data_proxy+ is one DataProxy or an array of them (classes or
476
+ # instances), run around every read and write of the model; other keywords are raw +@Erupt+ attributes merged onto the
477
+ # defaults (<tt>layout: { pageSize: 30 }</tt>); an unknown attribute or value fails here, not in the browser.
478
+ # A +vis:+ entry may also carry +filter+ (SQL, one or an array) and +orderBy+, applied by the node to that view.
479
+ def erupt(title = nil, name: nil, power: nil, order_by: nil, data_proxy: nil, **raw)
480
+ erupt_model.configure(title, name, power, order_by, data_proxy, raw)
481
+ end
482
+
483
+ # +kind+ infers the edit type: String INPUT, Integer / Float NUMBER, +:boolean+ BOOLEAN, Date DATE,
484
+ # Time DATE_TIME, another model class a reference; +choices:+ makes a CHOICE; +type:+ overrides.
485
+ def field(name, title = "", kind = nil, **options)
486
+ erupt_model.add_field(Field.new(name, title, kind, **options))
487
+ end
488
+
489
+ # A row operation; the block receives an OperationContext. A returned string is run as JavaScript in the
490
+ # browser, so only return code you wrote. +mode+: SINGLE (row button), MULTI (row and toolbar), MULTI_ONLY
491
+ # (toolbar), BUTTON (toolbar, no rows). +form+: a model class whose fields are asked for before running.
492
+ # +confirm+: false skips the confirm dialog, a string replaces its text. +show+: <tt>->(user) { ... }</tt>
493
+ # hides the operation and refuses to run it. +if_expr+: browser-side JS over +item+ that disables (or
494
+ # hides) the row button. Other keywords are raw +@RowOperation+ attributes.
495
+ def operation(code, title, **options, &handler)
496
+ erupt_model.add_operation(Operation.new(code, title, **options, &handler))
497
+ end
498
+ end
499
+ end