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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f84d68ffc08198006a4a6e0533eb474c59a12afd898454366efff89a289ab267
4
+ data.tar.gz: bd8868651a4662eaa2226414fbd8348f84056b4cfbe8859bb987595d2d0be6e3
5
+ SHA512:
6
+ metadata.gz: 53715a45d54b3e35fbfeca07deef44c1f2fe3ee769a8fd37bb842d9f918a5135697d2afec38a171e6498cb9a1d1ba58110a981dd435acb846aae728990e91374
7
+ data.tar.gz: f308104ca642f1d9e8e5aef727e9fca0bb9d4b9c00183af5adf84950dcdc29baafd4b5b6a78d2b614906f7606e3850dc7aaabde5c5ce497fb9ce0a017eabd302
data/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # erupt-sdk (Ruby)
2
+
3
+ Serve Ruby models as an erupt admin UI. Zero gem dependencies, Ruby 3.1+.
4
+
5
+ ```bash
6
+ gem install erupt-sdk # or in a Gemfile: gem "erupt-sdk"
7
+ ```
8
+
9
+ ```ruby
10
+ require "erupt"
11
+
12
+ class Article
13
+ extend Erupt::Model
14
+ erupt "Article", order_by: "id desc"
15
+ field :id, pk: true
16
+ field :title, "Title", search: true, not_null: true, sortable: true
17
+ field :status, "Status", choices: { draft: "Draft", published: "Published" }, search: true
18
+ field :publish_date, "Publish Date", Date, search: true
19
+ field :body, "Body", type: Erupt::EditType::TEXTAREA, table: false
20
+ end
21
+
22
+ node = Erupt::Node.new("blog", ENV.fetch("ERUPT_ACCESS_TOKEN"), "http://erupt-server:8080")
23
+ node.register(Article, Erupt::MemoryRepository.new)
24
+ node.serve(port: 9090)
25
+ ```
26
+
27
+ 1. On the erupt server create node `blog` (Node Config) and copy its access token.
28
+ 2. Add a menu of type `table` with value `blog.Article`.
29
+ 3. Run the node. The server must be able to reach it on `http://<node ip>:9090`; pass `address:` otherwise.
30
+
31
+ `field name, title, kind` infers the edit type from `kind`: `String` (or nothing) INPUT, `Integer` / `Float`
32
+ NUMBER, `:boolean` BOOLEAN, `Date` DATE, `Time` DATE_TIME, `choices:` CHOICE; `type:` overrides it.
33
+ `view: {...}` / `edit: {...}` on a field and extra keywords on `erupt` pass raw annotation attributes through.
34
+ Enum values have constants (`Erupt::EditType::TEXTAREA`, `Erupt::RowOperationMode::SINGLE`, ...).
35
+
36
+ Records are hashes keyed by field name (`row["title"]`); hashes handed to a repository may use symbols.
37
+
38
+ ## Rack
39
+
40
+ A node is a Rack application, so it can live inside an existing app instead of on its own port. `start`
41
+ registers it (with the port the server reaches it on) and `stop` deregisters it:
42
+
43
+ ```ruby
44
+ # config/routes.rb (Rails): mount at the root, the server calls /erupt-api/... and /node-health
45
+ mount ERUPT_NODE, at: "/"
46
+
47
+ # config/initializers/erupt.rb
48
+ ERUPT_NODE = Erupt::Node.new("blog", ENV.fetch("ERUPT_ACCESS_TOKEN"), ENV.fetch("ERUPT_SERVER"), logger: Rails.logger)
49
+ ERUPT_NODE.register(Article, repo)
50
+ ERUPT_NODE.start(port: 3000)
51
+ at_exit { ERUPT_NODE.stop }
52
+ ```
53
+
54
+ `serve` runs a small built-in threaded HTTP/1.1 server (keep-alive, chunked bodies) for the standalone case.
55
+
56
+ ## References
57
+
58
+ Pass another model class (declared earlier, registered on the same node) as the kind. The repository stores
59
+ the referenced primary key; the UI shows `label:` (default `name`).
60
+
61
+ ```ruby
62
+ class Category
63
+ extend Erupt::Model
64
+ erupt "Category"
65
+ field :id, pk: true
66
+ field :name, "Name", search: true
67
+ field :parent, "Parent Id", Integer
68
+ end
69
+
70
+ class Article
71
+ # ...
72
+ field :category, "Category", Category, search: true # table picker
73
+ field :topic, "Topic", Category, tree: :parent # tree picker over Category.parent
74
+ end
75
+ ```
76
+
77
+ ## Row operations
78
+
79
+ ```ruby
80
+ class PublishForm # a form model needs no primary key
81
+ extend Erupt::Model
82
+ erupt "Publish Options"
83
+ field :publish_date, "Publish Date", Date, not_null: true
84
+ end
85
+
86
+ class Article
87
+ # ...
88
+ operation :publish, "Publish", form: PublishForm, if_expr: "item.status !== 'published'" do |op|
89
+ op.rows.each { |row| op.repo.update(row["id"], status: "published", publish_date: op.param["publish_date"]) }
90
+ end # op.rows, op.param, op.repo, op.user
91
+ end
92
+ ```
93
+
94
+ `mode:` `SINGLE` / `MULTI` (default) / `MULTI_ONLY` / `BUTTON`. `confirm: false` skips the dialog, a string
95
+ replaces its text. `show: ->(user) { ... }` hides the operation and refuses to run it. A returned string is
96
+ executed as JavaScript in the browser, so return only code you wrote.
97
+
98
+ ## DataProxy
99
+
100
+ Hooks around a model's reads and writes, as erupt's Java `DataProxy`. Override what you need; raise
101
+ `Erupt::Fail` to refuse with a message the user sees.
102
+
103
+ ```ruby
104
+ class ArticleProxy < Erupt::DataProxy
105
+ def add_behavior(record) # the new form's values
106
+ record["status"] = "draft"
107
+ end
108
+
109
+ def validate(record) # before every add and update
110
+ raise Erupt::Fail, "no spam" if record["title"].to_s.include?("spam")
111
+ end
112
+
113
+ def before_fetch(conditions) # SQL ANDed to the table's WHERE, values bound
114
+ ["owner = ?", [Erupt.current_user["uid"]]]
115
+ end
116
+
117
+ def after_fetch(rows) # a table page, before it is sent
118
+ rows.each { |row| row["title"] = row["title"].strip }
119
+ end
120
+ end
121
+
122
+ class Article
123
+ extend Erupt::Model
124
+ erupt "Article", data_proxy: ArticleProxy # one proxy or an array, classes or instances
125
+ end
126
+ ```
127
+
128
+ | Hook | Runs |
129
+ |------|------|
130
+ | `search_condition(condition)` | building the model: default values of the search bar |
131
+ | `before_fetch(conditions)` | before a table page is read: SQL ANDed to its WHERE as written, `"sql"` or `["sql with ?", [values]]` (with values, each `?` is one of them); SQL repositories only, `MemoryRepository` refuses it |
132
+ | `after_fetch(rows)` | after a table page is read |
133
+ | `add_behavior(record)` | for the new record form (`init-value`) |
134
+ | `edit_behavior(record)` | after a record is read by its key (edit form, detail view) |
135
+ | `validate(record)` | before an add or an update |
136
+ | `before_add` / `after_add(record)` | around `repository.add` |
137
+ | `before_update` / `after_update(record)` | around `repository.update`, with the submitted values |
138
+ | `before_delete` / `after_delete(record)` | around `repository.delete`, once per record as stored |
139
+
140
+ ## Views
141
+
142
+ `vis:` declares the extra views of `@Erupt(vis = ...)`. A view's `filter` (one SQL string or an array, ANDed as
143
+ written) and `orderBy` (as `order_by`) are applied by the node when the table is read for that view, and kept
144
+ out of the build, as Java keeps them `@Transient`. A view without a `code` is addressed by its title. Like
145
+ `before_fetch`, a filter needs a SQL repository.
146
+
147
+ ```ruby
148
+ class Task
149
+ extend Erupt::Model
150
+ erupt "Task", vis: [
151
+ { title: "Board", type: "BOARD", boardView: { groupField: "status" }, filter: "archived = 0" },
152
+ { title: "Recent", type: "TABLE", orderBy: "created desc" }
153
+ ]
154
+ end
155
+ ```
156
+
157
+ Dragging on a board, calendar or gantt view is saved by the erupt server through the node's `get` and `update`.
158
+
159
+ ## Trees
160
+
161
+ `tree: { id: "id", label: "name", pid: "parent" }` shows the model as a tree, and
162
+ `linkTree: { field: "category" }` puts the tree of a reference field beside the table: the erupt server builds
163
+ both trees from the node's table, and the node keeps the rows under the picked tree node (`dependNode: true`
164
+ shows none until one is picked). `linkTree.field` must be a reference field.
165
+
166
+ ## Field types and field code
167
+
168
+ Besides the inferred types, `type:` takes `TAGS`, `AUTO_COMPLETE`, `ATTACHMENT` (the erupt server stores the
169
+ upload, the node keeps its path), `HTML_EDITOR`, `MARKDOWN`, `CODE_EDITOR` (all text), `BUTTON` (form only),
170
+ and `CHECKBOX` / `TAB_TREE` with `ref:`: the picked records, stored as the JSON list of their keys. An `Array`
171
+ field with `choices:` is a `MULTI_CHOICE`.
172
+
173
+ A field can run its own code; the erupt server calls it through the node's `invoke` endpoint with the form's
174
+ values:
175
+
176
+ ```ruby
177
+ class Order
178
+ extend Erupt::Model
179
+ erupt "Order"
180
+ field :id, pk: true
181
+ field :status, "Status", fetch: ->(form) { { new: "New", paid: "Paid" } } # options, also TAGS: [...]
182
+ field :qty, "Qty", Integer, onchange: ->(form) { { formData: { total: form["qty"] * 9 }, editExpr: {} } }
183
+ field :city, "City", type: Erupt::EditType::AUTO_COMPLETE, complete: ->(form, typed) { lookup(typed) }
184
+ field :total, "Total", Float
185
+ field :recalc, "Recalc", type: Erupt::EditType::BUTTON, click: ->(form) { { eval: "alert('done')", formData: {} } }
186
+ field :tags, "Tags", Array, type: Erupt::EditType::CHECKBOX, ref: Tag
187
+ end
188
+ ```
189
+
190
+ `fetch` belongs to `CHOICE` / `MULTI_CHOICE` (`{ value => label }` or pairs) and `TAGS` (an array), `complete`
191
+ to `AUTO_COMPLETE`, `click` to `BUTTON`; `onchange` to any field. Raise `Erupt::Fail` to refuse with a message.
192
+
193
+ Also: `COLOR`, `ICON`, `SIGNATURE`, `MAP`, `HIDDEN` (text), `RATE`, `SLIDER` (numbers) and `TRANSFER` (like
194
+ `CHECKBOX`). A reference table depending on another field takes
195
+ `edit: { referenceTableType: { dependField: "province", dependColumn: "province" } }`: the erupt server keeps the
196
+ picked model's rows whose `dependColumn` holds the depend field's value.
197
+
198
+ ## Drills
199
+
200
+ ```ruby
201
+ erupt "Author", drills: [{ title: "Books", link: { linkErupt: "Book", joinColumn: "author" } }]
202
+ ```
203
+
204
+ opens `Book` filtered to the rows whose `author` holds the author's `id` (`link.column`, `id` by default);
205
+ `link.linkCondition` adds SQL as written. Both models must be on the node.
206
+
207
+ ## Server config and user info
208
+
209
+ `node.config` and `node.group_config` return the config text set for the node and its group on the erupt
210
+ server; `node.user_info` the acting user's full info (roles, org, post), while a request is served.
211
+
212
+ ## Storage
213
+
214
+ Storage is any `Erupt::Repository` (`page`, `get`, `add`, `update`, `delete`). Shipped:
215
+
216
+ - `MemoryRepository` for tests and demos;
217
+ - `SqlRepository.new(executor, table, auto_create: false)` on PostgreSQL, MySQL or SQLite. The SDK depends on
218
+ no driver: an `Erupt::SqlExecutor` runs each statement with bound values on the driver you hand it. Create
219
+ the tables yourself, with columns named like the fields (`auto_create:` creates SQLite tables).
220
+
221
+ ```ruby
222
+ require "pg" # PostgreSQL (pg gem)
223
+ repo = Erupt::SqlRepository.new(Erupt::SqlExecutor.pg(PG.connect(ENV.fetch("DATABASE_URL"))), "article")
224
+
225
+ require "mysql2" # MySQL (mysql2 gem; statements are prepared)
226
+ repo = Erupt::SqlRepository.new(Erupt::SqlExecutor.mysql2(Mysql2::Client.new(**MYSQL)), "article")
227
+
228
+ require "sqlite3" # SQLite (sqlite3 gem)
229
+ repo = Erupt::SqlRepository.new(Erupt::SqlExecutor.sqlite3(SQLite3::Database.new("blog.db")), "article", auto_create: true)
230
+
231
+ require "connection_pool" # pooled: anything with `with` lends a connection per statement
232
+ repo = Erupt::SqlRepository.new(Erupt::SqlExecutor.pg(ConnectionPool.new(size: 5) { PG.connect(DSN) }), "article")
233
+
234
+ # any other library: a block that runs (sql, params) and returns rows as hashes keyed by column name
235
+ executor = Erupt::SqlExecutor.new(:postgres) do |sql, params|
236
+ ActiveRecord::Base.connection_pool.with_connection { |c| c.raw_connection.exec_params(sql, params).to_a }
237
+ end
238
+ ```
239
+
240
+ A single connection is used by one thread at a time. Without a sort, every repository returns rows by primary
241
+ key ascending. `Erupt.current_user` returns the acting user inside a repository call.
242
+
243
+ Not yet: sub-tables (TAB_TABLE_*), TPL operations, i18n of messages. Trees, file
244
+ upload and Excel are done by the erupt server.
245
+
246
+ ## Without a registry
247
+
248
+ From the repository, without RubyGems (Bundler):
249
+
250
+ ```ruby
251
+ gem "erupt-sdk", git: "https://github.com/erupt-io/erupt-sdk", tag: "ruby/v0.1.0", glob: "ruby/*.gemspec"
252
+ ```
253
+
254
+ Or copy `ruby/lib/` (`erupt.rb` and `erupt/`, including its `.json` files) onto your load path and
255
+ `require "erupt"`. A copy does not update itself: when erupt changes the protocol or the annotation schema, replace the whole directory with the next release instead of merging by hand, and leave `Erupt::VERSION` untouched so the server shows which release the node runs.
256
+
257
+ ## Generated code
258
+
259
+ `lib/erupt/types.rb` (a constant for every annotation enum value) and `sig/erupt/types.rbs` (RBS records for
260
+ every annotation attribute: `edit`, `view`, `erupt`, `row_operation`, ...) are generated from the erupt
261
+ annotation JSON Schema. Raw `view:` / `edit:` / `erupt` / `operation` attributes are checked against the same
262
+ schema when a model is declared, so a misspelt key or enum value fails at load instead of silently doing
263
+ nothing in the browser.
264
+
265
+ After an annotation change in erupt (regenerate there with `mvn -pl erupt-test -Pnode-schema test`):
266
+
267
+ ```bash
268
+ ruby tools/gen_types.rb ../schema # sync schemas, regenerate types.rb / types.rbs
269
+ ruby tools/gen_types.rb ../schema --check # CI: fail when stale
270
+ ```
271
+
272
+ Tests: `ruby -Ilib -e 'Dir["./test/test_*.rb"].each { |f| require f }'` (SQLite-backed cases run where the
273
+ `sqlite3` gem is installed; `ERUPT_TEST_DATABASES=1` with the `pg` and `mysql2` gems runs `test_databases.rb`).