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.
- checksums.yaml +7 -0
- data/README.md +273 -0
- data/lib/erupt/defaults.json +1577 -0
- data/lib/erupt/http_server.rb +115 -0
- data/lib/erupt/model.rb +499 -0
- data/lib/erupt/node.rb +555 -0
- data/lib/erupt/repository.rb +113 -0
- data/lib/erupt/schema.json +2137 -0
- data/lib/erupt/schema.rb +68 -0
- data/lib/erupt/signature.rb +30 -0
- data/lib/erupt/sql.rb +226 -0
- data/lib/erupt/types.rb +300 -0
- data/lib/erupt/version.rb +5 -0
- data/lib/erupt-sdk.rb +4 -0
- data/lib/erupt.rb +12 -0
- data/sig/erupt/types.rbs +408 -0
- metadata +56 -0
|
@@ -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
|
data/lib/erupt/model.rb
ADDED
|
@@ -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
|