edgeway 0.1.0 → 0.2.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +270 -9
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare.js +123 -22
  6. data/js/cors.js +15 -16
  7. data/js/headers.js +16 -0
  8. data/js/jwt.js +1 -1
  9. data/lib/edgeway/application.rb +6 -3
  10. data/lib/edgeway/cli/build/app_config.rb +39 -0
  11. data/lib/edgeway/cli/build/app_files.rb +40 -0
  12. data/lib/edgeway/cli/build/database_config.rb +92 -0
  13. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  14. data/lib/edgeway/cli/build.rb +36 -11
  15. data/lib/edgeway/cli/generate.rb +174 -26
  16. data/lib/edgeway/cli/new.rb +1 -1
  17. data/lib/edgeway/cli/test.rb +4 -1
  18. data/lib/edgeway/cli.rb +5 -4
  19. data/lib/edgeway/controller/base.rb +12 -2
  20. data/lib/edgeway/controller/dispatcher.rb +11 -1
  21. data/lib/edgeway/controller/rendering.rb +24 -4
  22. data/lib/edgeway/db/adapter.rb +38 -0
  23. data/lib/edgeway/db/memory.rb +9 -2
  24. data/lib/edgeway/db/query_stats.rb +46 -0
  25. data/lib/edgeway/db/sqlite.rb +73 -9
  26. data/lib/edgeway/errors.rb +22 -0
  27. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  28. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  29. data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
  30. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  31. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  32. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  33. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  34. data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
  35. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  36. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
  37. data/lib/edgeway/http/auth/stub.rb +1 -3
  38. data/lib/edgeway/http/request.rb +2 -22
  39. data/lib/edgeway/inflector.rb +19 -0
  40. data/lib/edgeway/model/attributes.rb +93 -10
  41. data/lib/edgeway/model/base.rb +50 -5
  42. data/lib/edgeway/model/connection_registry.rb +25 -0
  43. data/lib/edgeway/model/macros.rb +5 -1
  44. data/lib/edgeway/model/persistence.rb +18 -11
  45. data/lib/edgeway/model/relation.rb +106 -56
  46. data/lib/edgeway/model/validations.rb +29 -23
  47. data/lib/edgeway/routing/route.rb +1 -6
  48. data/lib/edgeway/server.rb +12 -0
  49. data/lib/edgeway/service/cloudflare/d1.rb +39 -10
  50. data/lib/edgeway/test.rb +29 -8
  51. data/lib/edgeway/version.rb +1 -1
  52. data/lib/edgeway/view.rb +24 -0
  53. data/lib/edgeway.rb +3 -0
  54. metadata +17 -4
  55. data/lib/edgeway/cli/app_config.rb +0 -35
  56. data/lib/edgeway/cli/app_files.rb +0 -37
data/js/cloudflare.js CHANGED
@@ -5,20 +5,101 @@
5
5
  // export default createWorker(app);
6
6
  //
7
7
  // build/edgeway/config.js is written by `edgeway build` from
8
- // config/database.yml: the environment name and the D1 binding.
8
+ // config/database.yml: the environment name, the D1 binding, and the bindings
9
+ // that use D1 Sessions.
9
10
  import { runCgi } from "./runtime.js";
10
11
  import config from "./config.js";
11
12
  import { jwtImports } from "./jwt.js";
12
13
  import { withCors } from "./cors.js";
14
+ import { BOOKMARK_HEADER, D1Sessions } from "./cloudflare-d1-sessions.js";
15
+
16
+ // D1's transient errors, which are worth a retry by the client
17
+ // (developers.cloudflare.com/d1/observability/debug-d1/). D1 retries read-only
18
+ // queries itself; Edgeway never retries, as a write is not idempotent.
19
+ // "exceeded", "CPU time limit", "memory limit" and "timeout" are not here.
20
+ export const TRANSIENT_D1_ERRORS = [
21
+ "Network connection lost",
22
+ "reset because its code was updated",
23
+ "storage caused object to be reset",
24
+ "Replica disconnected from primary",
25
+ "transient issue on remote node",
26
+ "Can't read from request stream because client disconnected",
27
+ "D1 DB is overloaded",
28
+ ];
29
+
30
+ export const isTransientD1Error = (message) => TRANSIENT_D1_ERRORS.some((s) => message.includes(s));
31
+
32
+ // Counts are 32-bit in wasm: a larger one is clamped.
33
+ const MAX_COUNT = 2147483647;
34
+
35
+ // D1's result.meta in the shape of META above. A local D1 (wrangler dev) may
36
+ // leave fields out.
37
+ export function metaOf(meta) {
38
+ return {
39
+ rows_read: Math.min(meta?.rows_read ?? 0, MAX_COUNT),
40
+ rows_written: Math.min(meta?.rows_written ?? 0, MAX_COUNT),
41
+ duration: meta?.duration ?? 0,
42
+ served_by_region: meta?.served_by_region ?? "",
43
+ served_by_primary: meta?.served_by_primary ?? true,
44
+ };
45
+ }
46
+
47
+ export function sumMeta(metas) {
48
+ const all = metas.map(metaOf);
49
+ const sum = (field) => all.reduce((total, meta) => total + meta[field], 0);
50
+ return {
51
+ ...all[all.length - 1],
52
+ rows_read: Math.min(sum("rows_read"), MAX_COUNT),
53
+ rows_written: Math.min(sum("rows_written"), MAX_COUNT),
54
+ duration: sum("duration"),
55
+ };
56
+ }
57
+
58
+ // A BLOB crosses as {"$blob": "<base64>"}. D1 binds an ArrayBuffer as a BLOB and
59
+ // answers a BLOB column as an Array of byte numbers.
60
+ export function bindOf(value) {
61
+ if (value === null || typeof value !== "object" || typeof value.$blob !== "string") return value;
62
+ return Uint8Array.from(atob(value.$blob), (char) => char.charCodeAt(0)).buffer;
63
+ }
64
+
65
+ const isBytes = (value) => Array.isArray(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
66
+
67
+ function bytesOf(value) {
68
+ if (Array.isArray(value)) return Uint8Array.from(value);
69
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
70
+ return new Uint8Array(value);
71
+ }
72
+
73
+ function base64Of(value) {
74
+ const bytes = bytesOf(value);
75
+ let binary = "";
76
+ for (let at = 0; at < bytes.length; at += 8192) binary += String.fromCharCode(...bytes.subarray(at, at + 8192));
77
+ return btoa(binary);
78
+ }
79
+
80
+ export function rowOf(row) {
81
+ for (const column of Object.keys(row)) {
82
+ const value = row[column];
83
+ if (value !== null && typeof value === "object" && isBytes(value)) row[column] = { $blob: base64Of(value) };
84
+ }
85
+ return row;
86
+ }
13
87
 
14
88
  // The wasm import module "edgeway_d1" (lib/edgeway/service/cloudflare/d1.rb). Each call
15
89
  // answers the byte length of its JSON result; take() copies it out.
16
- // query(binding, sql, binds) -> {"rows": [...]}
17
- // execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id}
18
- // batch(binding, statements) -> {"results": [{"changes": n, "last_insert_id": id}, ...]}
90
+ // query(binding, sql, binds) -> {"rows": [...], "meta": META}
91
+ // execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id, "meta": META}
92
+ // exec(binding, sql) -> {"count": n, "meta": META}
93
+ // (sql: statements separated by newlines, no binds; an error on a database with sessions)
94
+ // batch(binding, statements) -> {"results": [{"changes": n, "last_insert_id": id}, ...], "meta": META}
19
95
  // (statements: [[sql, binds], ...], one atomic D1 batch)
20
- // An error is {"error": "message"}.
21
- export function d1Imports(env, getInstance) {
96
+ // META is {"rows_read": n, "rows_written": n, "duration": ms, "served_by_region": "...",
97
+ // "served_by_primary": bool}; a batch's sums the statements'. exec's has only
98
+ // rows_read, rows_written and duration.
99
+ // An error is {"error": "message"} (no meta), with "unavailable": true when it is transient.
100
+ const SESSION_EXEC_ERROR = "exec() is not available on a database with sessions: true (a D1 session has no exec)";
101
+
102
+ export function d1Imports(env, getInstance, sessions = new D1Sessions(env)) {
22
103
  const mem = () => new Uint8Array(getInstance().exports.memory.buffer);
23
104
  const cstr = (p) => {
24
105
  const b = mem();
@@ -29,36 +110,47 @@ export function d1Imports(env, getInstance) {
29
110
  let pending = new Uint8Array(0);
30
111
 
31
112
  // fn(db, ...pointers) answers the result object; a failure is {error}.
32
- const suspending = (fn) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
113
+ const suspending = (fn, { withoutSessions = false } = {}) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
33
114
  let res;
34
115
  try {
35
116
  const name = cstr(bindingPtr) || config.d1Binding;
36
- const db = env[name];
117
+ if (withoutSessions && sessions.uses(name)) throw new Error(SESSION_EXEC_ERROR);
118
+ const db = sessions.database(name);
37
119
  if (!db) throw new Error(`no D1 binding ${name}`);
38
120
  res = await fn(db, ...ptrs);
39
121
  } catch (e) {
40
- res = { error: String(e?.message ?? e) };
122
+ const error = String(e?.message ?? e);
123
+ res = isTransientD1Error(error) ? { error, unavailable: true } : { error };
41
124
  }
42
125
  pending = new TextEncoder().encode(JSON.stringify(res));
43
126
  return pending.length;
44
127
  });
128
+ const prepare = (db, sql, binds) => db.prepare(sql).bind(...binds.map(bindOf));
45
129
  const run = (fn) => suspending((db, sqlPtr, bindsPtr) =>
46
- fn(db.prepare(cstr(sqlPtr)).bind(...JSON.parse(cstr(bindsPtr)))));
130
+ fn(prepare(db, cstr(sqlPtr), JSON.parse(cstr(bindsPtr)))));
47
131
 
48
132
  return {
49
133
  edgeway_d1: {
50
- query: run(async (st) => ({ rows: (await st.all()).results })),
51
- execute: run(async (st) => {
52
- const meta = (await st.run()).meta;
53
- return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0 };
134
+ query: run(async (statement) => {
135
+ const result = await statement.all();
136
+ return { rows: result.results.map(rowOf), meta: metaOf(result.meta) };
54
137
  }),
138
+ execute: run(async (statement) => {
139
+ const meta = (await statement.run()).meta;
140
+ return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0, meta: metaOf(meta) };
141
+ }),
142
+ exec: suspending(async (db, sqlPtr) => {
143
+ const result = await db.exec(cstr(sqlPtr));
144
+ return { count: result.count ?? 0, meta: { rows_read: 0, rows_written: 0, duration: result.duration ?? 0 } };
145
+ }, { withoutSessions: true }),
55
146
  // db.batch() is one transaction: a failing statement rejects it and
56
147
  // none of the statements stay applied.
57
148
  batch: suspending(async (db, statementsPtr) => {
58
149
  const statements = JSON.parse(cstr(statementsPtr));
59
- const results = await db.batch(statements.map(([sql, binds]) => db.prepare(sql).bind(...binds)));
150
+ const results = await db.batch(statements.map(([sql, binds]) => prepare(db, sql, binds)));
60
151
  return {
61
- results: results.map((r) => ({ changes: r.meta.changes ?? 0, last_insert_id: r.meta.last_row_id ?? 0 })),
152
+ results: results.map((result) => ({ changes: result.meta.changes ?? 0, last_insert_id: result.meta.last_row_id ?? 0 })),
153
+ meta: sumMeta(results.map((result) => result.meta)),
62
154
  };
63
155
  }),
64
156
  take(buf) {
@@ -90,14 +182,23 @@ export function varsOf(env) {
90
182
  return vars;
91
183
  }
92
184
 
185
+ export function corsOptions(sessionBindings) {
186
+ const names = sessionBindings.length > 0 ? [BOOKMARK_HEADER] : [];
187
+ return { exposeHeaders: names, allowHeaders: names };
188
+ }
189
+
93
190
  // appModule: the WebAssembly.Module that `import app from "...app.wasm"` gives.
94
191
  export function createWorker(appModule) {
95
192
  return {
96
- fetch: withCors((request, env) => runCgi(appModule, request, {
97
- extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
98
- // Both modules are always supplied, whether or not the app uses them:
99
- // the wasm instance needs every import it declares.
100
- imports: (getInstance) => ({ ...d1Imports(env, getInstance), ...jwtImports(env, getInstance) }),
101
- })),
193
+ fetch: withCors(async (request, env) => {
194
+ const sessions = new D1Sessions(env, request, config.sessions ?? []);
195
+ const response = await runCgi(appModule, request, {
196
+ extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
197
+ // Both modules are always supplied, whether or not the app uses them:
198
+ // the wasm instance needs every import it declares.
199
+ imports: (getInstance) => ({ ...d1Imports(env, getInstance, sessions), ...jwtImports(env, getInstance) }),
200
+ });
201
+ return sessions.annotate(response);
202
+ }, corsOptions(config.sessions ?? [])),
102
203
  };
103
204
  }
data/js/cors.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // status instead of an opaque CORS error. Nothing here is specific to a
5
5
  // platform. Cookies are not supported: Access-Control-Allow-Credentials is
6
6
  // never sent.
7
+ import { addVary, withHeaders } from "./headers.js";
8
+
7
9
  const DEFAULT_HEADERS = "Authorization, Content-Type";
8
10
  const DEFAULT_MAX_AGE = "600";
9
11
  const METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE";
@@ -15,17 +17,17 @@ function json(status, body, headers = {}) {
15
17
  // The settings from the Worker's variables, or null when CORS is off.
16
18
  // A bad CORS_MAX_AGE is { error }: like JWT, a misconfiguration is a 500, not
17
19
  // a silent default.
18
- function settings(env) {
19
- const list = (env.CORS_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
20
+ function settings(env, { exposeHeaders, allowHeaders }) {
21
+ const list = (env.CORS_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
20
22
  if (list.length === 0) return null;
21
23
  const maxAge = (env.CORS_MAX_AGE ?? "").trim() || DEFAULT_MAX_AGE;
22
24
  if (!/^\d+$/.test(maxAge)) return { error: `CORS_MAX_AGE must be a non-negative integer, got "${env.CORS_MAX_AGE}"` };
23
25
  return {
24
26
  any: list.includes("*"),
25
27
  origins: new Set(list),
26
- headers: (env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS,
28
+ headers: withHeaders((env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS, ...allowHeaders),
27
29
  maxAge,
28
- expose: (env.CORS_EXPOSE_HEADERS ?? "").trim(),
30
+ expose: withHeaders(env.CORS_EXPOSE_HEADERS, ...exposeHeaders),
29
31
  };
30
32
  }
31
33
 
@@ -36,20 +38,17 @@ function allowed(cfg, origin) {
36
38
  return cfg.any || cfg.origins.has(origin);
37
39
  }
38
40
 
39
- // Appends Origin to Vary unless it (or *) is already there.
40
- function addVary(headers, ...names) {
41
- const have = (headers.get("Vary") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
42
- if (have.includes("*")) return;
43
- for (const n of names) {
44
- if (!have.some((h) => h.toLowerCase() === n.toLowerCase())) have.push(n);
45
- }
46
- headers.set("Vary", have.join(", "));
41
+ function exposeOn(headers, expose) {
42
+ const merged = withHeaders(headers.get("Access-Control-Expose-Headers"), ...expose.split(", ").filter(Boolean));
43
+ if (merged) headers.set("Access-Control-Expose-Headers", merged);
47
44
  }
48
45
 
49
- // Wraps a fetch handler (request, env, ctx) -> Response.
50
- export function withCors(handler) {
46
+ // Wraps a fetch handler (request, env, ctx) -> Response. exposeHeaders and
47
+ // allowHeaders are names the platform layer always adds to the exposed and the
48
+ // preflight-allowed headers.
49
+ export function withCors(handler, { exposeHeaders = [], allowHeaders = [] } = {}) {
51
50
  return async (request, env, ctx) => {
52
- const cfg = settings(env);
51
+ const cfg = settings(env, { exposeHeaders, allowHeaders });
53
52
  if (!cfg) return handler(request, env, ctx);
54
53
  if (cfg.error) {
55
54
  console.error(cfg.error);
@@ -79,7 +78,7 @@ export function withCors(handler) {
79
78
  const out = new Response(res.body, res);
80
79
  if (ok) {
81
80
  out.headers.set("Access-Control-Allow-Origin", origin);
82
- if (cfg.expose) out.headers.set("Access-Control-Expose-Headers", cfg.expose);
81
+ exposeOn(out.headers, cfg.expose);
83
82
  }
84
83
  addVary(out.headers, "Origin");
85
84
  return out;
data/js/headers.js ADDED
@@ -0,0 +1,16 @@
1
+ const listOf = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
2
+
3
+ export function withHeaders(list, ...names) {
4
+ const have = listOf(list);
5
+ for (const name of names) {
6
+ if (!have.some((existing) => existing.toLowerCase() === name.toLowerCase())) have.push(name);
7
+ }
8
+ return have.join(", ");
9
+ }
10
+
11
+ // A Vary of * already covers every header.
12
+ export function addVary(headers, ...names) {
13
+ const have = listOf(headers.get("Vary"));
14
+ if (have.includes("*")) return;
15
+ headers.set("Vary", withHeaders(have.join(", "), ...names));
16
+ }
data/js/jwt.js CHANGED
@@ -245,7 +245,7 @@ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cac
245
245
  // verify(token) -> {"header": {...}, "payload": {...}}
246
246
  // | {"error": "message"} the token is bad
247
247
  // | {"error": "message", "unavailable": true} it can't be checked
248
- // Like D1, a call answers the byte length of the result and take() copies it
248
+ // Like the other imports, a call answers the byte length of the result and take() copies it
249
249
  // out; verify is wrapped in WebAssembly.Suspending (the WebCrypto and fetch
250
250
  // Promises are awaited with the wasm stack suspended).
251
251
  export function jwtImports(env, getInstance, options = {}) {
@@ -38,6 +38,11 @@ module Edgeway
38
38
  # The message names the table and columns: the log only.
39
39
  Edgeway.logger.info "409 #{e.message}"
40
40
  message(409, "record not unique")
41
+ elsif e.is_a?(DatabaseUnavailable)
42
+ Edgeway.logger.error "503 #{e.message}"
43
+ response = message(503, "database unavailable")
44
+ response.headers["Retry-After"] = "1"
45
+ response
41
46
  elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
42
47
  # The message can hold the path or an id from the request: the log only.
43
48
  Edgeway.logger.info "404 #{e.message}"
@@ -58,9 +63,7 @@ module Edgeway
58
63
  end
59
64
 
60
65
  def message(status, text)
61
- # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
62
- # Spinel refuses this String as it is, or emits C that doesn't compile.
63
- Controller::Rendering.json_response(status, JSON.generate({ "error" => text.dup }))
66
+ Controller::Rendering.json_response(status, JSON.generate({ "error" => text }))
64
67
  end
65
68
  end
66
69
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "database_config"
4
+
5
+ module Edgeway
6
+ module CLI
7
+ class Build
8
+ class AppConfig
9
+ # adapter: => the file build/main.rb requires for it.
10
+ ADAPTERS = {
11
+ "d1" => "edgeway/service/cloudflare/d1",
12
+ "memory" => "edgeway/db/memory"
13
+ }.freeze
14
+
15
+ attr_reader :env, :adapter, :binding_name, :databases, :sessions
16
+
17
+ def self.load(root, env)
18
+ config = DatabaseConfig.new(root)
19
+ section = config.section(env)
20
+ databases = config.databases_in(section, env)
21
+ sessions = config.session_bindings(section, env)
22
+ new(env, section["adapter"].to_s, config.primary_binding(section), databases, sessions)
23
+ end
24
+
25
+ def initialize(env, adapter, binding_name, databases = {}, sessions = [])
26
+ raise Error, "unknown database adapter #{adapter.inspect} (one of #{ADAPTERS.keys.join(", ")})" unless ADAPTERS.key?(adapter)
27
+
28
+ @env = env
29
+ @adapter = adapter
30
+ @binding_name = binding_name
31
+ @databases = databases
32
+ @sessions = adapter == "d1" ? sessions : []
33
+ end
34
+
35
+ def adapter_file = ADAPTERS.fetch(@adapter)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Build
6
+ class AppFiles
7
+ def initialize(root)
8
+ @root = root
9
+ end
10
+
11
+ def require_lines(views: nil)
12
+ lines = []
13
+ app = File.join(@root, "app")
14
+ dirs = Dir.exist?(app) ? Dir.children(app).sort.select { |d| File.directory?(File.join(app, d)) } : []
15
+ dirs.each do |d|
16
+ next if %w[models controllers].include?(d)
17
+
18
+ lines.concat(requires("app/#{d}"))
19
+ end
20
+ lines.concat(requires("app/models"))
21
+ lines.concat(requires("app/controllers"))
22
+ lines << "require_relative #{views.dump}" if views
23
+ lines << 'require_relative "../config/routes"'
24
+ lines
25
+ end
26
+
27
+ # require_relative lines for the .rb files under dir, application_*.rb first.
28
+ def requires(dir)
29
+ full = File.join(@root, dir)
30
+ return [] unless Dir.exist?(full)
31
+
32
+ files = Dir.glob("**/*.rb", base: full).map { |f| File.join(dir, f) }
33
+ first, rest = files.partition { |f| File.basename(f).start_with?("application_") }
34
+ # String#dump quotes a path with spaces, quotes or "#{" safely.
35
+ (first.sort + rest.sort).map { |f| "require_relative #{"../#{f.delete_suffix(".rb")}".dump}" }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgeway
4
+ module CLI
5
+ class Build
6
+ class DatabaseConfig
7
+ DATABASE_NAME = /\A[a-z_][a-z0-9_]*\z/
8
+
9
+ def initialize(root)
10
+ @root = root
11
+ end
12
+
13
+ def section(env)
14
+ section = sections[env]
15
+ raise Error, "config/database.yml has no #{env} section" unless section.is_a?(Hash)
16
+
17
+ section
18
+ end
19
+
20
+ def databases_in(section, env)
21
+ entries = section["databases"] || {}
22
+ raise Error, "config/database.yml: databases in #{env} must be a Hash" unless entries.is_a?(Hash)
23
+
24
+ entries.to_h do |name, entry|
25
+ name = name.to_s
26
+ [name, database_binding(name, entry, env)]
27
+ end
28
+ end
29
+
30
+ def primary_binding(section) = (section["binding"] || "DB").to_s
31
+
32
+ def session_bindings(section, env)
33
+ flags = {}
34
+ record_sessions(flags, "the #{env} section", primary_binding(section), section, "the #{env} section")
35
+ (section["databases"] || {}).each do |name, entry|
36
+ record_sessions(flags, "databases.#{name}", entry["binding"].to_s, entry, "databases.#{name} in #{env}")
37
+ end
38
+ flags.select { |_binding, (_label, enabled)| enabled }.keys
39
+ end
40
+
41
+ def names
42
+ names = []
43
+ return names unless File.exist?(path)
44
+
45
+ sections.each do |env, section|
46
+ next unless section.is_a?(Hash)
47
+ databases_in(section, env).each_key { |name| names << name unless names.include?(name) }
48
+ end
49
+ names
50
+ end
51
+
52
+ private
53
+
54
+ def path = File.join(@root, "config", "database.yml")
55
+
56
+ def sections
57
+ raise Error, "no config/database.yml in #{@root} (is this an Edgeway app?)" unless File.exist?(path)
58
+
59
+ YAML.safe_load_file(path) || {}
60
+ end
61
+
62
+ def record_sessions(flags, label, binding_name, entry, where)
63
+ enabled = sessions?(entry, where)
64
+ known_label, known = flags[binding_name]
65
+ if known_label && known != enabled
66
+ raise Error, "config/database.yml: #{known_label} and #{label} share binding #{binding_name} but differ in sessions"
67
+ end
68
+
69
+ flags[binding_name] = [label, enabled]
70
+ end
71
+
72
+ def sessions?(entry, where)
73
+ return false unless entry.is_a?(Hash) && entry.key?("sessions")
74
+ return entry["sessions"] if [true, false].include?(entry["sessions"])
75
+
76
+ raise Error, "config/database.yml: sessions in #{where} must be true or false"
77
+ end
78
+
79
+ def database_binding(name, entry, env)
80
+ unless name.match?(DATABASE_NAME) && name != "primary"
81
+ raise Error, "config/database.yml: bad database name #{name.inspect} in #{env} (lowercase letters, digits and _, and not primary)"
82
+ end
83
+
84
+ binding_name = entry.is_a?(Hash) ? entry["binding"].to_s : ""
85
+ raise Error, "config/database.yml: databases.#{name} in #{env} has no binding:" if binding_name.empty?
86
+
87
+ binding_name
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end