gemstack-db 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2 -26
- data/README.md +6 -20
- data/lib/gemstack-db.rb +5 -0
- metadata +20 -45
- data/lib/gemstack/db/configuration.rb +0 -148
- data/lib/gemstack/db/errors.rb +0 -137
- data/lib/gemstack/db/json_compat.rb +0 -21
- data/lib/gemstack/db/migrator.rb +0 -80
- data/lib/gemstack/db/model.rb +0 -226
- data/lib/gemstack/db/schema_types.rb +0 -28
- data/lib/gemstack/db/tasks.rb +0 -94
- data/lib/gemstack/db/testing.rb +0 -99
- data/lib/gemstack/db.rb +0 -210
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47396d58ea87b22d25ad2fd79a425224b1f2bd6709bbc9186fd58e7e6e0f8a49
|
|
4
|
+
data.tar.gz: 0c62733861805c0f951033fa2ce5e256874793d3129edddaf53ced3cb1cc12fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b99bd922ddc122d9b7cb74e0bb73b0874711ae53d63f589c4d5598f81e2c6a2631624f4773211f42ca8ccfa0c7aba6a4e2f20bd254604c085e3bd6658a09d3e1
|
|
7
|
+
data.tar.gz: 6be0513b6cbf84eb30fd80c2f61e6dda3a7cff5f1bcfed84fe37b7d45d7f9ae1e024cbb5094ab8e7642b1bd36ca72e1507ec04b823c4221aa8da833334c03463
|
data/CHANGELOG.md
CHANGED
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0
|
|
4
4
|
|
|
5
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
-
|
|
7
|
-
## 0.2.4
|
|
8
|
-
|
|
9
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
10
|
-
|
|
11
|
-
## 0.2.3
|
|
12
|
-
|
|
13
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
14
|
-
|
|
15
|
-
## 0.2.2
|
|
16
|
-
|
|
17
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
18
|
-
|
|
19
|
-
## 0.2.1
|
|
20
|
-
|
|
21
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
22
|
-
|
|
23
|
-
## 0.2.0
|
|
24
|
-
|
|
25
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
26
|
-
|
|
27
|
-
## 0.1.0
|
|
28
|
-
|
|
29
|
-
First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
5
|
+
Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
data/README.md
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
# gemstack-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
This version is a transition shim: it depends on `gemstack` and loads `gemstack/db`, so
|
|
6
|
+
Gemfiles that still list `gemstack-db` keep working. To finish upgrading, remove `gem "gemstack-db"`
|
|
7
|
+
from your Gemfile and make sure `config/app.rb` has `require "gemstack/db"` (apps created with 0.3.0 do).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Optional module — added by `gemstack new` (unless --skip-database):
|
|
12
|
-
|
|
13
|
-
```ruby
|
|
14
|
-
gem "gemstack-db", "~> 0.1"
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Documentation
|
|
18
|
-
|
|
19
|
-
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/database.md)
|
|
20
|
-
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
21
|
-
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
22
|
-
|
|
23
|
-
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
24
|
-
(this gem lives in `gems/gemstack-db`).
|
|
9
|
+
GemStack is a modular Ruby API framework for Next.js applications by
|
|
10
|
+
[Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
|
|
25
11
|
|
|
26
12
|
## License
|
|
27
13
|
|
data/lib/gemstack-db.rb
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack-db
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adware Technologies
|
|
@@ -11,47 +11,28 @@ cert_chain: []
|
|
|
11
11
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: gemstack
|
|
14
|
+
name: gemstack
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- -
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
20
|
-
|
|
21
|
-
prerelease: false
|
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
-
requirements:
|
|
24
|
-
- - '='
|
|
25
|
-
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.2.5
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: gemstack-schema
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - '='
|
|
19
|
+
version: 0.3.0
|
|
20
|
+
- - "<"
|
|
32
21
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: 0
|
|
22
|
+
version: '1.0'
|
|
34
23
|
type: :runtime
|
|
35
24
|
prerelease: false
|
|
36
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
26
|
requirements:
|
|
38
|
-
- -
|
|
27
|
+
- - ">="
|
|
39
28
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: 0.
|
|
41
|
-
-
|
|
42
|
-
name: sequel
|
|
43
|
-
requirement: !ruby/object:Gem::Requirement
|
|
44
|
-
requirements:
|
|
45
|
-
- - "~>"
|
|
46
|
-
- !ruby/object:Gem::Version
|
|
47
|
-
version: '5.80'
|
|
48
|
-
type: :runtime
|
|
49
|
-
prerelease: false
|
|
50
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
-
requirements:
|
|
52
|
-
- - "~>"
|
|
29
|
+
version: 0.3.0
|
|
30
|
+
- - "<"
|
|
53
31
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
32
|
+
version: '1.0'
|
|
33
|
+
description: Since GemStack 0.3.0, gemstack-db is part of the gemstack gem. This version
|
|
34
|
+
only depends on gemstack and loads gemstack/db, so Gemfiles that still list it keep
|
|
35
|
+
working.
|
|
55
36
|
email:
|
|
56
37
|
- gemstack26@gmail.com
|
|
57
38
|
executables: []
|
|
@@ -61,24 +42,19 @@ files:
|
|
|
61
42
|
- CHANGELOG.md
|
|
62
43
|
- LICENSE.txt
|
|
63
44
|
- README.md
|
|
64
|
-
- lib/gemstack
|
|
65
|
-
- lib/gemstack/db/configuration.rb
|
|
66
|
-
- lib/gemstack/db/errors.rb
|
|
67
|
-
- lib/gemstack/db/json_compat.rb
|
|
68
|
-
- lib/gemstack/db/migrator.rb
|
|
69
|
-
- lib/gemstack/db/model.rb
|
|
70
|
-
- lib/gemstack/db/schema_types.rb
|
|
71
|
-
- lib/gemstack/db/tasks.rb
|
|
72
|
-
- lib/gemstack/db/testing.rb
|
|
45
|
+
- lib/gemstack-db.rb
|
|
73
46
|
homepage: https://github.com/gemstack-rb/gemstack
|
|
74
47
|
licenses:
|
|
75
48
|
- MIT
|
|
76
49
|
metadata:
|
|
77
50
|
rubygems_mfa_required: 'true'
|
|
78
|
-
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
79
|
-
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/
|
|
51
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
52
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
|
|
80
53
|
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
81
54
|
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
55
|
+
post_install_message: gemstack-db is now part of the gemstack gem. Remove `gem "gemstack-db"`
|
|
56
|
+
from your Gemfile and make sure `config/app.rb` has `require "gemstack/db"` (apps
|
|
57
|
+
created with 0.3.0 do).
|
|
82
58
|
rdoc_options: []
|
|
83
59
|
require_paths:
|
|
84
60
|
- lib
|
|
@@ -95,6 +71,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
95
71
|
requirements: []
|
|
96
72
|
rubygems_version: 4.0.20
|
|
97
73
|
specification_version: 4
|
|
98
|
-
summary:
|
|
99
|
-
MySQL'
|
|
74
|
+
summary: Merged into the gemstack gem — remove gemstack-db from your Gemfile
|
|
100
75
|
test_files: []
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "erb"
|
|
4
|
-
require "uri"
|
|
5
|
-
require "yaml"
|
|
6
|
-
|
|
7
|
-
module GemStack
|
|
8
|
-
module DB
|
|
9
|
-
# Where the connection settings come from, first match wins:
|
|
10
|
-
#
|
|
11
|
-
# 1. config.db.url, set in config/app.rb or config/environments/*.rb
|
|
12
|
-
# 2. DATABASE_URL (TEST_DATABASE_URL in the test environment)
|
|
13
|
-
# 3. config/database.yml — the section for the current environment
|
|
14
|
-
# 4. PostgreSQL on this machine, database <app>_<env> (apps without database.yml)
|
|
15
|
-
#
|
|
16
|
-
# config/database.yml works like Rails' (ERB allowed):
|
|
17
|
-
#
|
|
18
|
-
# default: &default
|
|
19
|
-
# adapter: postgresql # postgresql, mysql2, trilogy or sqlite3
|
|
20
|
-
# pool: <%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
|
|
21
|
-
# development:
|
|
22
|
-
# <<: *default
|
|
23
|
-
# database: shop_development
|
|
24
|
-
# test:
|
|
25
|
-
# <<: *default
|
|
26
|
-
# database: shop_test
|
|
27
|
-
# production:
|
|
28
|
-
# <<: *default
|
|
29
|
-
# url: <%= ENV["DATABASE_URL"] %>
|
|
30
|
-
#
|
|
31
|
-
# The result is a Hash for Sequel.connect: { adapter: "postgres", database:, host:, … }.
|
|
32
|
-
module Configuration
|
|
33
|
-
# Rails-style names (and URL schemes) → Sequel adapters.
|
|
34
|
-
ADAPTERS = {
|
|
35
|
-
"postgresql" => "postgres", "postgres" => "postgres", "postgis" => "postgres",
|
|
36
|
-
"mysql2" => "mysql2", "mysql" => "mysql2", "trilogy" => "trilogy",
|
|
37
|
-
"sqlite3" => "sqlite", "sqlite" => "sqlite"
|
|
38
|
-
}.freeze
|
|
39
|
-
# The gem each adapter needs in the application's Gemfile.
|
|
40
|
-
DRIVER_GEMS = { "postgres" => "pg", "mysql2" => "mysql2", "trilogy" => "trilogy", "sqlite" => "sqlite3" }.freeze
|
|
41
|
-
# database.yml keys with another name in Sequel.
|
|
42
|
-
RENAMED = { "username" => :user, "pool" => :max_connections }.freeze
|
|
43
|
-
# database.yml keys Rails uses that mean nothing to Sequel.
|
|
44
|
-
IGNORED = %w[schema_search_path migrations_paths reaping_frequency idle_timeout checkout_timeout].freeze
|
|
45
|
-
|
|
46
|
-
module_function
|
|
47
|
-
|
|
48
|
-
def resolve(config: DB.config, env: GemStack.env, root: GemStack.config.root)
|
|
49
|
-
return from_url(config.url, root: root).merge(source: "config.db.url") if config.url
|
|
50
|
-
|
|
51
|
-
variable = env.test? ? "TEST_DATABASE_URL" : "DATABASE_URL"
|
|
52
|
-
url = ENV.fetch(variable, "").strip
|
|
53
|
-
yaml = yaml_settings(root, env)
|
|
54
|
-
return from_url(url, root: root).merge(pool_from(yaml)).merge(source: variable) unless url.empty?
|
|
55
|
-
return yaml.merge(source: "config/database.yml (#{env})") if yaml
|
|
56
|
-
|
|
57
|
-
name = "#{GemStack.config.name.to_s.tr("-", "_")}_#{env}"
|
|
58
|
-
{ adapter: "postgres", database: name, source: "default (no config/database.yml)" }
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The section of config/database.yml for this environment, normalized; nil without the file.
|
|
62
|
-
def yaml_settings(root, env)
|
|
63
|
-
path = File.join(root.to_s, "config", "database.yml")
|
|
64
|
-
return nil unless File.file?(path)
|
|
65
|
-
|
|
66
|
-
data = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true, filename: path) || {}
|
|
67
|
-
section = data[env.to_s] or raise ConfigurationError, "config/database.yml has no #{env} section"
|
|
68
|
-
raise ConfigurationError, "config/database.yml: #{env} must be a mapping" unless section.is_a?(Hash)
|
|
69
|
-
|
|
70
|
-
from_hash(section, root: root)
|
|
71
|
-
rescue Psych::Exception => e
|
|
72
|
-
raise ConfigurationError, "config/database.yml: #{e.message}"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def from_hash(section, root:)
|
|
76
|
-
url = section["url"].to_s.strip
|
|
77
|
-
base = url.empty? ? {} : from_url(url, root: root)
|
|
78
|
-
settings = section.each_with_object({}) do |(key, value), out|
|
|
79
|
-
next if key == "url" || IGNORED.include?(key) || value.nil?
|
|
80
|
-
|
|
81
|
-
out[RENAMED.fetch(key, key.to_sym)] = value
|
|
82
|
-
end
|
|
83
|
-
settings = base.merge(settings) { |_, from_url, from_keys| from_keys || from_url }
|
|
84
|
-
settings[:adapter] = adapter!(settings[:adapter]) if settings[:adapter]
|
|
85
|
-
raise ConfigurationError, "config/database.yml: set adapter (or url)" unless settings[:adapter]
|
|
86
|
-
|
|
87
|
-
settings[:max_connections] = Integer(settings[:max_connections]) if settings[:max_connections]
|
|
88
|
-
settings[:port] = Integer(settings[:port]) if settings[:port]
|
|
89
|
-
sqlite_path(settings, root)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# postgres://user:pass@host:5432/name?sslmode=require, mysql2://…, trilogy://…,
|
|
93
|
-
# sqlite3:db/development.sqlite3 (relative to the app), sqlite3:///abs/path.db, sqlite3::memory:
|
|
94
|
-
def from_url(url, root:)
|
|
95
|
-
scheme = url[/\A([a-z0-9+.-]+):/i, 1].to_s.downcase
|
|
96
|
-
adapter = adapter!(scheme)
|
|
97
|
-
if adapter == "sqlite"
|
|
98
|
-
path = url.sub(/\A[^:]+:/, "").sub(%r{\A//}, "").sub(/\?.*\z/, "")
|
|
99
|
-
return sqlite_path({ adapter: adapter, database: path }, root)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
uri = URI.parse(url)
|
|
103
|
-
settings = { adapter: adapter, host: uri.host, port: uri.port,
|
|
104
|
-
database: decode(uri.path.to_s.delete_prefix("/")), user: decode(uri.user),
|
|
105
|
-
password: decode(uri.password) }
|
|
106
|
-
URI.decode_www_form(uri.query.to_s).each { |key, value| settings[key.to_sym] = value }
|
|
107
|
-
settings.reject { |_, value| value.nil? || value == "" }
|
|
108
|
-
rescue URI::InvalidURIError => e
|
|
109
|
-
raise ConfigurationError, "invalid database URL: #{e.message}"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def adapter!(name)
|
|
113
|
-
ADAPTERS.fetch(name.to_s.downcase) do
|
|
114
|
-
raise ConfigurationError, "unsupported database adapter #{name.inspect} " \
|
|
115
|
-
"(use postgresql, mysql2, trilogy or sqlite3)"
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def pool_from(yaml) = yaml && yaml[:max_connections] ? { max_connections: yaml[:max_connections] } : {}
|
|
120
|
-
|
|
121
|
-
def sqlite_path(settings, root)
|
|
122
|
-
return settings unless settings[:adapter] == "sqlite"
|
|
123
|
-
|
|
124
|
-
database = settings[:database].to_s
|
|
125
|
-
if database.empty?
|
|
126
|
-
raise ConfigurationError,
|
|
127
|
-
"sqlite3 needs a database file (e.g. database: db/development.sqlite3)"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
settings[:database] = File.expand_path(database, root.to_s) unless database == ":memory:"
|
|
131
|
-
settings
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def decode(value) = value && URI.decode_www_form_component(value)
|
|
135
|
-
|
|
136
|
-
# For logs and `gemstack doctor`: no passwords.
|
|
137
|
-
def describe(settings)
|
|
138
|
-
if settings[:adapter] == "sqlite"
|
|
139
|
-
return "sqlite3 #{settings[:database].to_s.delete_prefix("#{GemStack.config.root}/")}"
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
host = settings[:host] || "localhost"
|
|
143
|
-
user = settings[:user] ? "#{settings[:user]}@" : ""
|
|
144
|
-
"#{settings[:adapter]}://#{user}#{host}#{":#{settings[:port]}" if settings[:port]}/#{settings[:database]}"
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
data/lib/gemstack/db/errors.rb
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module DB
|
|
5
|
-
# Gives Sequel errors their HTTP meaning via GemStack::ErrorMapping (no
|
|
6
|
-
# dependency on the HTTP layer), for PostgreSQL, MySQL and SQLite:
|
|
7
|
-
#
|
|
8
|
-
# record not found → 404 not_found
|
|
9
|
-
# model validation failed → 422 validation_failed + field errors
|
|
10
|
-
# unique / not-null / FK → 422 with the offending column when the database names it
|
|
11
|
-
# row still referenced (FK) → 409 conflict
|
|
12
|
-
# database unreachable → 503 service_unavailable
|
|
13
|
-
module Errors
|
|
14
|
-
KEY_DETAIL = /Key \(([^)]+)\)=/ # PostgreSQL
|
|
15
|
-
MYSQL_KEY = /Duplicate entry .* for key '(?:[^.']+\.)?([^']+)'/ # MySQL 8 names table.index
|
|
16
|
-
SQLITE_COLUMNS = /constraint failed: ((?:[\w.]+(?:, )?)+)/i # UNIQUE / NOT NULL
|
|
17
|
-
FK_COLUMN = /FOREIGN KEY \(`?([^`)]+)`?\)/ # MySQL
|
|
18
|
-
|
|
19
|
-
module_function
|
|
20
|
-
|
|
21
|
-
def install!
|
|
22
|
-
# Registered first: the specific classes below take precedence.
|
|
23
|
-
ErrorMapping.register(Sequel::DatabaseError) { |error| mysql_missing_value(error) }
|
|
24
|
-
install_record_errors!
|
|
25
|
-
install_constraint_errors!
|
|
26
|
-
ErrorMapping.register(Sequel::DatabaseConnectionError) { ServiceUnavailable.new("Database unavailable") }
|
|
27
|
-
ErrorMapping.register(Sequel::PoolTimeout) { ServiceUnavailable.new("Database busy, try again") }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def install_record_errors!
|
|
31
|
-
ErrorMapping.register(Sequel::NoMatchingRow) do |error|
|
|
32
|
-
model = error.respond_to?(:dataset) && error.dataset.respond_to?(:model) ? error.dataset.model : nil
|
|
33
|
-
RecordNotFound.new(model&.name ? "#{model.name} not found" : "Record not found")
|
|
34
|
-
end
|
|
35
|
-
ErrorMapping.register(Sequel::ValidationFailed) do |error|
|
|
36
|
-
ValidationError.new(errors: stringify(error.errors))
|
|
37
|
-
end
|
|
38
|
-
ErrorMapping.register(Sequel::InvalidValue) do |error|
|
|
39
|
-
ValidationError.new(error.message.sub(/\A.*?: /, "Invalid value: "), code: "invalid_value")
|
|
40
|
-
end
|
|
41
|
-
ErrorMapping.register(Sequel::MassAssignmentRestriction) do |error|
|
|
42
|
-
BadRequest.new(error.message, code: "unknown_attribute")
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def install_constraint_errors!
|
|
47
|
-
ErrorMapping.register(Sequel::UniqueConstraintViolation) do |error|
|
|
48
|
-
field_error(columns(error), "is already taken") || Conflict.new("Record already exists")
|
|
49
|
-
end
|
|
50
|
-
ErrorMapping.register(Sequel::NotNullConstraintViolation) do |error|
|
|
51
|
-
field_error([column(error)].compact, "is required") || ValidationError.new("A required value is missing")
|
|
52
|
-
end
|
|
53
|
-
ErrorMapping.register(Sequel::ForeignKeyConstraintViolation) do |error|
|
|
54
|
-
if referenced_missing?(error)
|
|
55
|
-
field_error(columns(error), "does not exist") || ValidationError.new("A referenced record does not exist")
|
|
56
|
-
else
|
|
57
|
-
Conflict.new("Record is still referenced by other records", code: "still_referenced")
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
ErrorMapping.register(Sequel::CheckConstraintViolation) do
|
|
61
|
-
ValidationError.new("A value violates a database constraint", code: "constraint_violation")
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# MySQL (strict mode) reports an omitted NOT NULL column without a
|
|
66
|
-
# default as a plain error: "Field 'name' doesn't have a default value".
|
|
67
|
-
def mysql_missing_value(error)
|
|
68
|
-
name = error.message[/Field '([^']+)' doesn't have a default value/, 1]
|
|
69
|
-
name && ValidationError.new(errors: { name => ["is required"] })
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def stringify(errors) = errors.to_h { |key, messages| [Array(key).join(","), Array(messages)] }
|
|
73
|
-
|
|
74
|
-
def field_error(names, message)
|
|
75
|
-
return nil if names.empty?
|
|
76
|
-
|
|
77
|
-
ValidationError.new(errors: names.to_h { |name| [name, [message]] })
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def pg_error(error) = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
|
|
81
|
-
|
|
82
|
-
# PostgreSQL: "is not present in table"; MySQL: "Cannot add or update a child row".
|
|
83
|
-
# SQLite doesn't say which side failed, so it is reported as a conflict.
|
|
84
|
-
def referenced_missing?(error)
|
|
85
|
-
message = error.message
|
|
86
|
-
message.include?("is not present") || message.include?("Cannot add or update a child row")
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# The columns named by a unique or foreign-key violation, when the database says.
|
|
90
|
-
def columns(error)
|
|
91
|
-
message = error.message
|
|
92
|
-
if (detail = pg_detail(error) || message[KEY_DETAIL]) && (match = KEY_DETAIL.match(detail))
|
|
93
|
-
match[1].split(",").map(&:strip).map { |c| c.delete('"') }
|
|
94
|
-
elsif (match = SQLITE_COLUMNS.match(message))
|
|
95
|
-
match[1].split(", ").map { |name| name.split(".").last }
|
|
96
|
-
elsif (match = FK_COLUMN.match(message))
|
|
97
|
-
[match[1]]
|
|
98
|
-
elsif (match = MYSQL_KEY.match(message))
|
|
99
|
-
index_column(match[1])
|
|
100
|
-
else
|
|
101
|
-
[]
|
|
102
|
-
end
|
|
103
|
-
rescue StandardError
|
|
104
|
-
[]
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def column(error)
|
|
108
|
-
message = error.message
|
|
109
|
-
(defined?(PG::PG_DIAG_COLUMN_NAME) && pg_error(error)&.result&.error_field(PG::PG_DIAG_COLUMN_NAME)) ||
|
|
110
|
-
message[/column "([^"]+)"/, 1] || message[/Column '([^']+)' cannot be null/, 1] ||
|
|
111
|
-
sqlite_column(message)
|
|
112
|
-
rescue StandardError
|
|
113
|
-
nil
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def sqlite_column(message)
|
|
117
|
-
list = message[SQLITE_COLUMNS, 1] or return nil
|
|
118
|
-
list.split(", ").first.split(".").last
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def pg_detail(error)
|
|
122
|
-
return nil unless defined?(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
123
|
-
|
|
124
|
-
pg_error(error)&.result&.error_field(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# MySQL reports the index, not the column. `unique: true` on a column
|
|
128
|
-
# (what the generators write) names the index after the column; other
|
|
129
|
-
# indexes (table_column_index, composite) can't be mapped to one field.
|
|
130
|
-
def index_column(name)
|
|
131
|
-
name == "PRIMARY" || name.match?(/_(?:key|unique|uniq|index)\z/) ? [] : [name]
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
GemStack::DB::Errors.install!
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module DB
|
|
5
|
-
# Sequel (5.108 and earlier) parses JSON/JSONB columns with
|
|
6
|
-
# JSON.parse(json, create_additions: false), an option json 3.0 removed, so
|
|
7
|
-
# every jsonb read raises ArgumentError. JSON.parse never creates additions,
|
|
8
|
-
# so dropping the option keeps Sequel's behaviour.
|
|
9
|
-
module JSONCompat
|
|
10
|
-
def parse_json(json) = JSON.parse(json)
|
|
11
|
-
|
|
12
|
-
def self.install!
|
|
13
|
-
return unless Gem::Version.new(JSON::VERSION) >= Gem::Version.new("3.0")
|
|
14
|
-
|
|
15
|
-
Sequel.singleton_class.prepend(self)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
GemStack::DB::JSONCompat.install!
|
data/lib/gemstack/db/migrator.rb
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
Sequel.extension :migration
|
|
4
|
-
|
|
5
|
-
module GemStack
|
|
6
|
-
module DB
|
|
7
|
-
# Timestamped Sequel migrations in db/migrations:
|
|
8
|
-
#
|
|
9
|
-
# # db/migrations/20260928120000_create_products.rb
|
|
10
|
-
# Sequel.migration do
|
|
11
|
-
# change do
|
|
12
|
-
# create_table(:products) do
|
|
13
|
-
# primary_key :id, type: :Bignum
|
|
14
|
-
# String :name, null: false
|
|
15
|
-
# timestamptz :created_at, null: false
|
|
16
|
-
# end
|
|
17
|
-
# end
|
|
18
|
-
# end
|
|
19
|
-
#
|
|
20
|
-
# Migrations are plain Sequel migrations; the full Sequel schema DSL applies.
|
|
21
|
-
class Migrator
|
|
22
|
-
Entry = Struct.new(:version, :name, :file, :applied, keyword_init: true)
|
|
23
|
-
|
|
24
|
-
attr_reader :db, :path
|
|
25
|
-
|
|
26
|
-
def initialize(database = DB.connection, path = GemStack.root.join(DB.config.migrations_path).to_s)
|
|
27
|
-
@db = database
|
|
28
|
-
@path = path.to_s
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def files = Dir.glob(File.join(path, "*.rb"))
|
|
32
|
-
|
|
33
|
-
# Applies pending migrations (or migrates up/down to target). Returns the
|
|
34
|
-
# file names that were applied or reverted.
|
|
35
|
-
def migrate(target: nil)
|
|
36
|
-
before = applied
|
|
37
|
-
run(target)
|
|
38
|
-
after = applied
|
|
39
|
-
(after - before) + (before - after)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Reverts the last `steps` applied migrations.
|
|
43
|
-
def rollback(steps: 1)
|
|
44
|
-
versions = applied.map { |file| version_of(file) }.sort
|
|
45
|
-
return [] if versions.empty?
|
|
46
|
-
|
|
47
|
-
target = versions[-(steps + 1)] || 0
|
|
48
|
-
migrate(target: target)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def status
|
|
52
|
-
done = applied
|
|
53
|
-
files.map do |file|
|
|
54
|
-
base = File.basename(file)
|
|
55
|
-
Entry.new(version: version_of(base), name: base.sub(/\A\d+_/, "").delete_suffix(".rb"), file: base,
|
|
56
|
-
applied: done.include?(base))
|
|
57
|
-
end.sort_by(&:version)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def pending = status.reject(&:applied)
|
|
61
|
-
def pending? = !pending.empty?
|
|
62
|
-
|
|
63
|
-
def applied
|
|
64
|
-
return [] if DB.table_missing?(db, :schema_migrations)
|
|
65
|
-
|
|
66
|
-
db[:schema_migrations].select_map(:filename)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
def run(target)
|
|
72
|
-
return unless File.directory?(path)
|
|
73
|
-
|
|
74
|
-
Sequel::TimestampMigrator.new(db, path, target: target).run
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def version_of(file) = File.basename(file)[/\A\d+/].to_i
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
data/lib/gemstack/db/model.rb
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
# Base class for models: Sequel::Model plus GemStack conventions.
|
|
5
|
-
#
|
|
6
|
-
# class Product < GemStack::Model
|
|
7
|
-
# field :name, :string, null: false, size: 120
|
|
8
|
-
# field :price, :decimal, null: false, gt: 0
|
|
9
|
-
# field :active, :boolean, null: false, default: true
|
|
10
|
-
#
|
|
11
|
-
# validates :name, format: /\A\S/
|
|
12
|
-
# belongs_to :category
|
|
13
|
-
# has_many :reviews
|
|
14
|
-
# end
|
|
15
|
-
#
|
|
16
|
-
# `field` declarations are metadata that drive validations, request
|
|
17
|
-
# schemas (Product.input_schema), serializer types and TypeScript. The
|
|
18
|
-
# database schema itself comes from migrations; everything Sequel offers
|
|
19
|
-
# (datasets, associations, hooks, plugins) is available unchanged.
|
|
20
|
-
#
|
|
21
|
-
# Created with Class.new on purpose: an *anonymous* subclass stops Sequel
|
|
22
|
-
# from binding GemStack::Model itself to a "models" table (which would need
|
|
23
|
-
# a database connection just to require this file).
|
|
24
|
-
Model = Class.new(Sequel::Model) # rubocop:disable Style/EmptyClassDefinition
|
|
25
|
-
|
|
26
|
-
class Model
|
|
27
|
-
Field = Struct.new(:name, :type, :options)
|
|
28
|
-
|
|
29
|
-
FIELD_RULES = %i[null default size gt gte lt lte in format].freeze
|
|
30
|
-
|
|
31
|
-
plugin :timestamps, update_on_create: true
|
|
32
|
-
plugin :validation_helpers
|
|
33
|
-
|
|
34
|
-
# A model may load before its migration has run (e.g. while generating the
|
|
35
|
-
# API contract right after `generate resource`); queries still fail
|
|
36
|
-
# clearly with "relation does not exist".
|
|
37
|
-
self.require_valid_table = false
|
|
38
|
-
|
|
39
|
-
class << self
|
|
40
|
-
def gemstack_fields
|
|
41
|
-
@gemstack_fields ||= superclass.respond_to?(:gemstack_fields) ? superclass.gemstack_fields.dup : {}
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Declares a field. Options: null: false (required), default:, size:
|
|
45
|
-
# (max length), gt/gte/lt/lte, in:, format:. Types are GemStack::Types.
|
|
46
|
-
def field(name, type, **options)
|
|
47
|
-
Types.fetch(type)
|
|
48
|
-
unknown = options.keys - FIELD_RULES
|
|
49
|
-
raise ArgumentError, "unknown field option(s) #{unknown.inspect} for #{name}" unless unknown.empty?
|
|
50
|
-
|
|
51
|
-
@input_schemas = nil
|
|
52
|
-
serialize_json(name) if Types::CLASS_ALIASES.fetch(type, type).to_sym == :json
|
|
53
|
-
gemstack_fields[name.to_sym] = Field.new(name.to_sym, Types::CLASS_ALIASES.fetch(type, type).to_sym,
|
|
54
|
-
options.freeze)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def gemstack_validations
|
|
58
|
-
@gemstack_validations ||=
|
|
59
|
-
superclass.respond_to?(:gemstack_validations) ? superclass.gemstack_validations.dup : []
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# validates :name, :sku, presence: true, length: { max: 40 }, format: /\A[A-Z]/,
|
|
63
|
-
# inclusion: { in: %w[a b] }, numericality: { gt: 0 }, uniqueness: true
|
|
64
|
-
def validates(*names, **rules)
|
|
65
|
-
unknown = rules.keys - %i[presence length format inclusion numericality uniqueness]
|
|
66
|
-
raise ArgumentError, "unknown validation(s) #{unknown.inspect}" unless unknown.empty?
|
|
67
|
-
|
|
68
|
-
gemstack_validations << [names.map(&:to_sym), rules]
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# A request schema derived from the field declarations (memoized):
|
|
72
|
-
# accepts :create, with: Product.input_schema
|
|
73
|
-
def input_schema(only: nil, except: nil)
|
|
74
|
-
key = [only, except]
|
|
75
|
-
(@input_schemas ||= {})[key] ||= Schema.from_model(
|
|
76
|
-
self, only: only, except: except, type_name: "#{name.to_s.split("::").join}Input"
|
|
77
|
-
)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# find(42) raises GemStack::DB::RecordNotFound (404) when missing.
|
|
81
|
-
# With a Hash or block it behaves like Sequel's find (nil when missing).
|
|
82
|
-
def find(*args, &block)
|
|
83
|
-
return super if block || args.size != 1 || args.first.is_a?(Hash)
|
|
84
|
-
|
|
85
|
-
id = args.first
|
|
86
|
-
record = valid_primary_key?(id) ? with_pk(id) : nil
|
|
87
|
-
record || raise(DB::RecordNotFound, "#{name} #{id} not found")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def find_by(conditions) = first(conditions)
|
|
91
|
-
def find_by!(conditions) = first(conditions) || raise(DB::RecordNotFound, "#{name} not found")
|
|
92
|
-
|
|
93
|
-
alias create! create
|
|
94
|
-
|
|
95
|
-
# Associations with familiar names; the Sequel names work too.
|
|
96
|
-
def belongs_to(name, **) = many_to_one(name, **)
|
|
97
|
-
def has_many(name, **) = one_to_many(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
98
|
-
def has_one(name, **) = one_to_one(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
99
|
-
def has_and_belongs_to_many(name, **) = many_to_many(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
# PostgreSQL's jsonb comes back as Hash/Array (pg_json); MySQL JSON and
|
|
104
|
-
# SQLite text come back as strings, so those fields are (de)serialized.
|
|
105
|
-
def serialize_json(name)
|
|
106
|
-
return if db.database_type == :postgres
|
|
107
|
-
|
|
108
|
-
plugin :serialization unless respond_to?(:serialization_map)
|
|
109
|
-
serialize_attributes :json, name unless serialization_map.key?(name.to_sym)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# For a table that doesn't exist yet, skip Sequel's schema queries: they
|
|
113
|
-
# would fail and log two errors per model (e.g. while `gemstack contract`
|
|
114
|
-
# runs before `db:migrate`). One cheap catalog lookup decides.
|
|
115
|
-
def get_db_schema(reload = reload_db_schema?)
|
|
116
|
-
return super unless missing_table?
|
|
117
|
-
|
|
118
|
-
set_columns(nil)
|
|
119
|
-
{}
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def missing_table?
|
|
123
|
-
return false unless @dataset
|
|
124
|
-
|
|
125
|
-
DB.table_missing?(db, dataset.first_source_table)
|
|
126
|
-
rescue Sequel::Error
|
|
127
|
-
false
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def valid_primary_key?(id)
|
|
131
|
-
return true unless integer_primary_key?
|
|
132
|
-
|
|
133
|
-
id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A\d{1,19}\z/))
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def integer_primary_key?
|
|
137
|
-
return @integer_primary_key if defined?(@integer_primary_key)
|
|
138
|
-
|
|
139
|
-
@integer_primary_key = primary_key.is_a?(Symbol) && db_schema.dig(primary_key, :type) == :integer
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
alias update! update
|
|
144
|
-
|
|
145
|
-
# Identifies this version of the record, for ETags (Controller#stale?) and
|
|
146
|
-
# cache keys: "product/42-1759052159.123456".
|
|
147
|
-
def cache_key
|
|
148
|
-
stamp = respond_to?(:updated_at) && updated_at ? "-#{updated_at.to_f}" : ""
|
|
149
|
-
"#{Inflector.underscore(self.class.name.to_s)}/#{pk}#{stamp}"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def validate
|
|
153
|
-
super
|
|
154
|
-
validate_fields
|
|
155
|
-
self.class.gemstack_validations.each { |names, rules| apply_validations(names, rules) }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Models are never rendered implicitly: exposing every column is how
|
|
159
|
-
# APIs leak data. Define a serializer instead.
|
|
160
|
-
def as_json(*)
|
|
161
|
-
file = "#{Inflector.underscore(self.class.name)}_serializer.rb"
|
|
162
|
-
raise Error, "#{self.class.name} has no serializer. Create #{file} " \
|
|
163
|
-
"in app/serializers (class #{self.class.name}Serializer < GemStack::Serializer)."
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def to_json(*) = as_json
|
|
167
|
-
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
def validate_fields
|
|
171
|
-
self.class.gemstack_fields.each_value do |field|
|
|
172
|
-
opts = field.options
|
|
173
|
-
name = field.name
|
|
174
|
-
if opts[:null] == false && !opts.key?(:default)
|
|
175
|
-
if field.type == :boolean
|
|
176
|
-
validates_includes([true, false], name, message: "must be true or false")
|
|
177
|
-
else
|
|
178
|
-
validates_presence(name, message: "is required")
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
apply_rules(name, opts)
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def apply_rules(name, opts)
|
|
186
|
-
if opts[:size].is_a?(Integer)
|
|
187
|
-
validates_max_length(opts[:size], name, message: "is too long (maximum #{opts[:size]} characters)",
|
|
188
|
-
allow_nil: true)
|
|
189
|
-
end
|
|
190
|
-
{ gt: :>, gte: :>=, lt: :<, lte: :<= }.each do |key, operator|
|
|
191
|
-
next unless opts.key?(key)
|
|
192
|
-
|
|
193
|
-
words = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
|
|
194
|
-
validates_operator(operator, opts[key], name, message: "must be #{words[key]} #{opts[key]}", allow_nil: true)
|
|
195
|
-
end
|
|
196
|
-
if opts[:in]
|
|
197
|
-
validates_includes(opts[:in], name, message: "must be one of: #{opts[:in].to_a.join(", ")}",
|
|
198
|
-
allow_nil: true)
|
|
199
|
-
end
|
|
200
|
-
validates_format(opts[:format], name, message: "is invalid", allow_nil: true) if opts[:format]
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def apply_validations(names, rules)
|
|
204
|
-
names.each do |name|
|
|
205
|
-
validates_presence(name, message: "is required") if rules[:presence]
|
|
206
|
-
length = rules[:length]
|
|
207
|
-
if length
|
|
208
|
-
apply_rules(name, size: length[:max]) if length[:max]
|
|
209
|
-
if length[:min]
|
|
210
|
-
validates_min_length(length[:min], name, message: "is too short (minimum #{length[:min]} characters)",
|
|
211
|
-
allow_nil: true)
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
apply_rules(name, format: rules[:format]) if rules[:format]
|
|
215
|
-
inclusion = rules[:inclusion]
|
|
216
|
-
apply_rules(name, in: inclusion.is_a?(Hash) ? inclusion[:in] : inclusion) if inclusion
|
|
217
|
-
numeric = rules[:numericality]
|
|
218
|
-
if numeric
|
|
219
|
-
validates_numeric(name, message: "must be a number", allow_nil: true)
|
|
220
|
-
apply_rules(name, numeric.slice(:gt, :gte, :lt, :lte)) if numeric.is_a?(Hash)
|
|
221
|
-
end
|
|
222
|
-
validates_unique(name, message: "is already taken") if rules[:uniqueness]
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module GemStack
|
|
4
|
-
module DB
|
|
5
|
-
# Migrations may use PostgreSQL's type names — the generators write
|
|
6
|
-
# `column :created_at, :timestamptz` — and still run on MySQL and SQLite,
|
|
7
|
-
# where each maps to the closest native type. Everything
|
|
8
|
-
# else in the Sequel schema DSL is untouched.
|
|
9
|
-
module SchemaTypes
|
|
10
|
-
MAPPINGS = {
|
|
11
|
-
mysql: { "timestamptz" => "datetime(6)", "timestamp" => "datetime(6)", "jsonb" => "json",
|
|
12
|
-
"uuid" => "char(36)", "inet" => "varchar(45)", "citext" => "varchar(255)" },
|
|
13
|
-
sqlite: { "timestamptz" => "timestamp", "jsonb" => "json", "uuid" => "varchar(36)",
|
|
14
|
-
"inet" => "varchar(45)", "citext" => "varchar(255)" }
|
|
15
|
-
}.freeze
|
|
16
|
-
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
def type_literal_specific(column)
|
|
20
|
-
type = column[:type]
|
|
21
|
-
mapped = MAPPINGS.dig(database_type, type.to_s.downcase) if type.is_a?(Symbol) || type.is_a?(String)
|
|
22
|
-
mapped || super
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
Sequel::Database.prepend(GemStack::DB::SchemaTypes)
|
data/lib/gemstack/db/tasks.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
|
|
5
|
-
module GemStack
|
|
6
|
-
module DB
|
|
7
|
-
# Database lifecycle used by `gemstack db:*`, for every adapter:
|
|
8
|
-
# PostgreSQL and MySQL through a server connection without the database,
|
|
9
|
-
# SQLite by creating or deleting the file.
|
|
10
|
-
#
|
|
11
|
-
# Each method takes resolved settings (DB.settings, the default) or a URL.
|
|
12
|
-
module Tasks
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
def settings_for(target)
|
|
16
|
-
target.is_a?(String) ? Configuration.from_url(target, root: GemStack.config.root) : target.except(:source)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# The database name (SQLite: the file, relative to the app).
|
|
20
|
-
def database_name(target = DB.settings)
|
|
21
|
-
name = settings_for(target)[:database].to_s
|
|
22
|
-
name.delete_prefix("#{GemStack.config.root}/")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def exists?(target = DB.settings)
|
|
26
|
-
settings = settings_for(target)
|
|
27
|
-
name = settings[:database].to_s
|
|
28
|
-
case settings[:adapter]
|
|
29
|
-
when "sqlite" then name == ":memory:" || File.file?(name)
|
|
30
|
-
when "postgres" then with_server(settings) { |db| db[:pg_database].where(datname: name).any? }
|
|
31
|
-
else with_server(settings) { |db| db[Sequel[:information_schema][:schemata]].where(schema_name: name).any? }
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Returns :created or :exists.
|
|
36
|
-
def create(target = DB.settings)
|
|
37
|
-
settings = settings_for(target)
|
|
38
|
-
return :exists if exists?(settings)
|
|
39
|
-
|
|
40
|
-
name = settings[:database].to_s
|
|
41
|
-
case settings[:adapter]
|
|
42
|
-
when "sqlite"
|
|
43
|
-
FileUtils.mkdir_p(File.dirname(name))
|
|
44
|
-
Sequel.connect(**settings, test: true, keep_reference: false).disconnect # creates the file
|
|
45
|
-
when "postgres"
|
|
46
|
-
with_server(settings) { |db| db.run("CREATE DATABASE #{db.literal(Sequel.identifier(name))}") }
|
|
47
|
-
else
|
|
48
|
-
with_server(settings) do |db|
|
|
49
|
-
db.run("CREATE DATABASE #{db.literal(Sequel.identifier(name))} " \
|
|
50
|
-
"CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci")
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
:created
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Refuses outside development/test unless GEMSTACK_ALLOW_DB_DROP=1.
|
|
57
|
-
def drop(target = DB.settings)
|
|
58
|
-
unless GemStack.env.local? || ENV["GEMSTACK_ALLOW_DB_DROP"] == "1"
|
|
59
|
-
raise Error, "refusing to drop the #{GemStack.env} database; set GEMSTACK_ALLOW_DB_DROP=1 to confirm"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
settings = settings_for(target)
|
|
63
|
-
return :missing unless exists?(settings)
|
|
64
|
-
|
|
65
|
-
DB.disconnect
|
|
66
|
-
name = settings[:database].to_s
|
|
67
|
-
case settings[:adapter]
|
|
68
|
-
when "sqlite" then FileUtils.rm_f(["", "-wal", "-shm", "-journal"].map { |suffix| "#{name}#{suffix}" })
|
|
69
|
-
when "postgres"
|
|
70
|
-
with_server(settings) { |db| db.run("DROP DATABASE #{db.literal(Sequel.identifier(name))} WITH (FORCE)") }
|
|
71
|
-
else with_server(settings) { |db| db.run("DROP DATABASE #{db.literal(Sequel.identifier(name))}") }
|
|
72
|
-
end
|
|
73
|
-
:dropped
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def seed(path = GemStack.root.join(DB.config.seeds_path))
|
|
77
|
-
return false unless File.file?(path)
|
|
78
|
-
|
|
79
|
-
load path.to_s
|
|
80
|
-
true
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# A connection to the server itself: PostgreSQL's "postgres" maintenance
|
|
84
|
-
# database, or MySQL without a default database.
|
|
85
|
-
def with_server(settings)
|
|
86
|
-
server = settings.merge(database: settings[:adapter] == "postgres" ? "postgres" : nil).compact
|
|
87
|
-
db = Sequel.connect(**server, max_connections: 1, test: true, keep_reference: false)
|
|
88
|
-
yield db
|
|
89
|
-
ensure
|
|
90
|
-
db&.disconnect
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
data/lib/gemstack/db/testing.rb
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "gemstack/db"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
|
|
6
|
-
module GemStack
|
|
7
|
-
module DB
|
|
8
|
-
# Test support, used by the generated test/test_helper.rb:
|
|
9
|
-
#
|
|
10
|
-
# require "gemstack/db/testing"
|
|
11
|
-
# GemStack::DB::Testing.prepare! # create + migrate the test database
|
|
12
|
-
# GemStack::TestCase.include GemStack::DB::Testing::Transactions
|
|
13
|
-
module Testing
|
|
14
|
-
module_function
|
|
15
|
-
|
|
16
|
-
# Creates the test database if needed and applies pending migrations,
|
|
17
|
-
# so `gemstack test` works on a fresh checkout.
|
|
18
|
-
def prepare!
|
|
19
|
-
Tasks.create
|
|
20
|
-
Migrator.new.migrate
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Valid attribute values for a model, derived from its `field`
|
|
24
|
-
# declarations (types, size, gt/gte/lt/lte, in:). Unique-looking values
|
|
25
|
-
# use a sequence; `references` fields create the referenced record.
|
|
26
|
-
# Fields with a `format:` rule may need an override.
|
|
27
|
-
#
|
|
28
|
-
# Product.create(GemStack::DB::Testing.sample_attributes(Product, name: "Lamp"))
|
|
29
|
-
def sample_attributes(model, overrides = {})
|
|
30
|
-
@sequence = (@sequence || 0) + 1
|
|
31
|
-
model.gemstack_fields.each_value.with_object({}) do |field, attrs|
|
|
32
|
-
attrs[field.name] = sample_value(model, field, @sequence)
|
|
33
|
-
end.merge(overrides)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# sample_attributes as JSON-ready values (for request bodies).
|
|
37
|
-
def sample_payload(model, overrides = {})
|
|
38
|
-
sample_attributes(model, overrides).transform_values do |value|
|
|
39
|
-
case value
|
|
40
|
-
when BigDecimal then value.to_s("F")
|
|
41
|
-
when Time then value.utc.iso8601
|
|
42
|
-
when Date then value.iso8601
|
|
43
|
-
else value
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def sample_value(model, field, sequence)
|
|
49
|
-
opts = field.options
|
|
50
|
-
return Array(opts[:in]).first if opts[:in]
|
|
51
|
-
|
|
52
|
-
case field.type
|
|
53
|
-
when :string, :text then sample_string(field, opts, sequence)
|
|
54
|
-
when :integer, :bigint then bounded(opts, 1, step: 1).to_i
|
|
55
|
-
when :float then bounded(opts, 1.5, step: 1).to_f
|
|
56
|
-
when :decimal then BigDecimal(bounded(opts, BigDecimal("9.99"), step: 1).to_s)
|
|
57
|
-
when :boolean then true
|
|
58
|
-
when :date then Date.today
|
|
59
|
-
when :datetime then Time.now.utc.round
|
|
60
|
-
when :uuid then SecureRandom.uuid
|
|
61
|
-
when :json then { "sample" => true }
|
|
62
|
-
when :references then sample_reference(model, field)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def sample_string(field, opts, sequence)
|
|
67
|
-
value = "#{Inflector.humanize(field.name)} #{sequence}"
|
|
68
|
-
opts[:size].is_a?(Integer) ? value[0, opts[:size]] : value
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def bounded(opts, value, step:)
|
|
72
|
-
value = opts[:gt] + step if opts[:gt] && value <= opts[:gt]
|
|
73
|
-
value = opts[:gte] if opts[:gte] && value < opts[:gte]
|
|
74
|
-
value = opts[:lt] - step if opts[:lt] && value >= opts[:lt]
|
|
75
|
-
value = opts[:lte] if opts[:lte] && value > opts[:lte]
|
|
76
|
-
value
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def sample_reference(model, field)
|
|
80
|
-
reflection = model.association_reflections.values.find { |r| r[:key] == field.name }
|
|
81
|
-
target = reflection&.associated_class ||
|
|
82
|
-
Object.const_get(Inflector.camelize(field.name.to_s.delete_suffix("_id")))
|
|
83
|
-
target.create(sample_attributes(target)).pk
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Runs every test inside a transaction that is rolled back afterwards.
|
|
87
|
-
# Requests made through rack-test run on the same thread, so they see
|
|
88
|
-
# (and roll back) the same data. Nested GemStack.transaction calls
|
|
89
|
-
# become savepoints.
|
|
90
|
-
module Transactions
|
|
91
|
-
def run(...)
|
|
92
|
-
result = nil
|
|
93
|
-
DB.connection.transaction(rollback: :always, auto_savepoint: true) { result = super }
|
|
94
|
-
result
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
data/lib/gemstack/db.rb
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "sequel"
|
|
4
|
-
require "json"
|
|
5
|
-
require "gemstack/core"
|
|
6
|
-
require "gemstack/schema"
|
|
7
|
-
|
|
8
|
-
require_relative "db/json_compat"
|
|
9
|
-
require_relative "db/configuration"
|
|
10
|
-
require_relative "db/schema_types"
|
|
11
|
-
|
|
12
|
-
module GemStack
|
|
13
|
-
# SQL databases through Sequel: PostgreSQL, MySQL
|
|
14
|
-
# (mysql2 or trilogy) and SQLite, configured by config/database.yml or
|
|
15
|
-
# DATABASE_URL. Optional: add `gem "gemstack-db"` and the driver gem
|
|
16
|
-
# (pg, mysql2, trilogy or sqlite3) to the Gemfile — `gemstack new` does.
|
|
17
|
-
#
|
|
18
|
-
# GemStack.db[:products].where(active: true).count # the Sequel::Database
|
|
19
|
-
# GemStack.transaction { order.save; payment.save }
|
|
20
|
-
module DB
|
|
21
|
-
class Config < Settings
|
|
22
|
-
# An explicit database URL; wins over DATABASE_URL and config/database.yml
|
|
23
|
-
# (see DB::Configuration). Test uses TEST_DATABASE_URL, never DATABASE_URL,
|
|
24
|
-
# so a development database from .env can't be wiped by the test suite.
|
|
25
|
-
setting :url, default: nil
|
|
26
|
-
# One connection per server thread by default (database.yml `pool:` wins).
|
|
27
|
-
setting :pool_size, default: lambda {
|
|
28
|
-
Integer(ENV.fetch("GEMSTACK_DB_POOL") { ENV.fetch("GEMSTACK_MAX_THREADS", 5) })
|
|
29
|
-
}
|
|
30
|
-
setting :pool_timeout, default: 5
|
|
31
|
-
setting :migrations_path, default: "db/migrations"
|
|
32
|
-
setting :seeds_path, default: "db/seeds.rb"
|
|
33
|
-
setting :log_queries, default: -> { GemStack.env.development? }
|
|
34
|
-
# Queries slower than this are logged at WARN in every environment.
|
|
35
|
-
setting :slow_query_ms, default: 500
|
|
36
|
-
# Server-side statement timeout in ms (nil = the database's default).
|
|
37
|
-
# PostgreSQL: statement_timeout; MySQL: max_execution_time (SELECTs); SQLite: none.
|
|
38
|
-
setting :statement_timeout, default: nil
|
|
39
|
-
# Sequel extensions to load; nil = pg_json + pg_array on PostgreSQL, none elsewhere.
|
|
40
|
-
setting :extensions, default: nil
|
|
41
|
-
# Extra options passed to Sequel.connect.
|
|
42
|
-
setting :options, default: {}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
class RecordNotFound < NotFound; end
|
|
46
|
-
|
|
47
|
-
# Stored and compared in UTC on every adapter (MySQL DATETIME and SQLite
|
|
48
|
-
# text have no time zone); PostgreSQL timestamptz is unaffected.
|
|
49
|
-
Sequel.database_timezone = :utc
|
|
50
|
-
|
|
51
|
-
# Adapts Sequel's logging to the GemStack logger: SQL at DEBUG only when
|
|
52
|
-
# config.db.log_queries is on, slow queries at WARN, and failures of the
|
|
53
|
-
# existence probes Sequel runs on purpose (table_exists?, model setup
|
|
54
|
-
# before a migration) are not reported as errors.
|
|
55
|
-
class QueryLogger
|
|
56
|
-
PROBE = /: SELECT (?:NULL AS [`"']nil[`"'] FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
|
|
57
|
-
|
|
58
|
-
def initialize(logger, queries:)
|
|
59
|
-
@logger = logger
|
|
60
|
-
@queries = queries
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def debug(message) = (@logger.debug(message) if @queries)
|
|
64
|
-
def info(message) = @logger.info(message)
|
|
65
|
-
def warn(message) = @logger.warn(message)
|
|
66
|
-
def error(message) = (@logger.error(message) unless PROBE.match?(message.to_s))
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
@mutex = Mutex.new
|
|
70
|
-
|
|
71
|
-
class << self
|
|
72
|
-
def config = GemStack.config.db
|
|
73
|
-
|
|
74
|
-
def connection
|
|
75
|
-
@connection || @mutex.synchronize { @connection ||= connect }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def connected? = !@connection.nil?
|
|
79
|
-
|
|
80
|
-
# The resolved connection settings (a Hash; see DB::Configuration).
|
|
81
|
-
def settings = @settings ||= Configuration.resolve
|
|
82
|
-
|
|
83
|
-
# "postgres", "mysql2", "trilogy" or "sqlite".
|
|
84
|
-
def adapter = settings[:adapter]
|
|
85
|
-
def postgres?(db = nil) = type(db) == :postgres
|
|
86
|
-
def mysql?(db = nil) = type(db) == :mysql
|
|
87
|
-
def sqlite?(db = nil) = type(db) == :sqlite
|
|
88
|
-
|
|
89
|
-
# Sequel's database type (:postgres, :mysql, :sqlite) — from the
|
|
90
|
-
# connection when there is one, so no query is needed.
|
|
91
|
-
def type(db = nil)
|
|
92
|
-
db ||= @connection
|
|
93
|
-
return db.database_type if db
|
|
94
|
-
|
|
95
|
-
{ "postgres" => :postgres, "mysql2" => :mysql, "trilogy" => :mysql, "sqlite" => :sqlite }.fetch(adapter)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Builds the Sequel::Database. Connections are opened lazily, so an
|
|
99
|
-
# unavailable database doesn't prevent the application from booting.
|
|
100
|
-
def connect(target = settings)
|
|
101
|
-
target = Configuration.from_url(target, root: GemStack.config.root) if target.is_a?(String)
|
|
102
|
-
opts = target.except(:source)
|
|
103
|
-
options = { max_connections: opts.delete(:max_connections) || config.pool_size,
|
|
104
|
-
pool_timeout: config.pool_timeout, test: false, keep_reference: false }
|
|
105
|
-
options.merge!(adapter_options(opts[:adapter], opts[:database]))
|
|
106
|
-
db = open_database(opts.merge(options).merge(config.options))
|
|
107
|
-
configure(db)
|
|
108
|
-
Sequel::Model.db = db
|
|
109
|
-
db
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Closes pooled connections (e.g. before Puma forks workers). The
|
|
113
|
-
# Database object stays in place and reconnects lazily: models hold a
|
|
114
|
-
# reference to it, so replacing it would split them from GemStack.db.
|
|
115
|
-
def disconnect
|
|
116
|
-
@connection&.disconnect
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def transaction(**, &) = connection.transaction(**, &)
|
|
120
|
-
|
|
121
|
-
# Forgets the resolved settings and the connection (tests, console).
|
|
122
|
-
def reset!
|
|
123
|
-
@connection&.disconnect
|
|
124
|
-
@connection = nil
|
|
125
|
-
@settings = nil
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# True when the table isn't there — one catalog query, nothing logged
|
|
129
|
-
# (Sequel's table_exists? probes with a failing SELECT).
|
|
130
|
-
def table_missing?(db, table)
|
|
131
|
-
name = table.to_s
|
|
132
|
-
case db.database_type
|
|
133
|
-
when :postgres then db.get(Sequel.function(:to_regclass, db.literal(table))).nil?
|
|
134
|
-
when :mysql
|
|
135
|
-
db[Sequel[:information_schema][:tables]].where(table_schema: Sequel.function(:database),
|
|
136
|
-
table_name: name).empty?
|
|
137
|
-
when :sqlite then db[:sqlite_master].where(type: %w[table view], name: name).empty?
|
|
138
|
-
else !db.table_exists?(table)
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
def configure(db)
|
|
145
|
-
extensions = config.extensions || (postgres?(db) ? %i[pg_json pg_array] : [])
|
|
146
|
-
db.extension(*extensions) unless extensions.empty?
|
|
147
|
-
db.log_warn_duration = config.slow_query_ms / 1000.0 if config.slow_query_ms
|
|
148
|
-
db.loggers << QueryLogger.new(GemStack.logger, queries: config.log_queries)
|
|
149
|
-
db.sql_log_level = :debug
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def open_database(options)
|
|
153
|
-
Sequel.connect(**options)
|
|
154
|
-
rescue Sequel::AdapterNotFound, LoadError => e
|
|
155
|
-
gem_name = Configuration::DRIVER_GEMS.fetch(options[:adapter], options[:adapter])
|
|
156
|
-
raise ConfigurationError, "#{options[:adapter]} needs gem \"#{gem_name}\" in the Gemfile (#{e.message})"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def adapter_options(adapter, database)
|
|
160
|
-
timeout = config.statement_timeout && Integer(config.statement_timeout)
|
|
161
|
-
case adapter
|
|
162
|
-
when "postgres" then timeout ? { connect_sqls: ["SET statement_timeout = #{timeout}"] } : {}
|
|
163
|
-
when "mysql2", "trilogy"
|
|
164
|
-
sqls = timeout ? ["SET SESSION max_execution_time = #{timeout}"] : []
|
|
165
|
-
{ encoding: "utf8mb4", connect_sqls: sqls }
|
|
166
|
-
when "sqlite"
|
|
167
|
-
# Several Puma threads share the file: wait for the write lock instead
|
|
168
|
-
# of failing, and let readers work while one connection writes.
|
|
169
|
-
sqls = database == ":memory:" ? [] : ["PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL"]
|
|
170
|
-
{ timeout: 5_000, connect_sqls: sqls }
|
|
171
|
-
else {}
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
# A model base class bound to an explicit table, like Sequel::Model(:table):
|
|
178
|
-
# class Item < GemStack::Model(:inventory_items)
|
|
179
|
-
def self.Model(source) = Model.Model(source) # rubocop:disable Naming/MethodName
|
|
180
|
-
|
|
181
|
-
class << self
|
|
182
|
-
def db = DB.connection
|
|
183
|
-
def transaction(**, &) = DB.transaction(**, &)
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
require_relative "db/model"
|
|
188
|
-
require_relative "db/errors"
|
|
189
|
-
require_relative "db/migrator"
|
|
190
|
-
require_relative "db/tasks"
|
|
191
|
-
|
|
192
|
-
GemStack::Config.namespace(:db, GemStack::DB::Config)
|
|
193
|
-
|
|
194
|
-
GemStack::Plugins.register(:db) do |app|
|
|
195
|
-
GemStack::DB.connection
|
|
196
|
-
app.on_shutdown { GemStack::DB.disconnect } if app.respond_to?(:on_shutdown)
|
|
197
|
-
|
|
198
|
-
# A friendly nudge in development; never blocks boot.
|
|
199
|
-
if GemStack.env.development?
|
|
200
|
-
begin
|
|
201
|
-
pending = GemStack::DB::Migrator.new.pending
|
|
202
|
-
unless pending.empty?
|
|
203
|
-
GemStack.logger.warn("#{pending.size} pending migration(s) — run `gemstack db:migrate`",
|
|
204
|
-
first: pending.first.file)
|
|
205
|
-
end
|
|
206
|
-
rescue Sequel::Error
|
|
207
|
-
nil # database not reachable yet; requests will report it
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|