gemstack-db 0.1.0 → 0.2.1
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 +8 -0
- data/README.md +2 -2
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +58 -12
- data/lib/gemstack/db/migrator.rb +1 -1
- data/lib/gemstack/db/model.rb +12 -2
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +51 -20
- data/lib/gemstack/db.rb +100 -23
- metadata +9 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf134933c7903511b62c753226076a98b1ae0d927167ae8007d32f9afb6abc27
|
|
4
|
+
data.tar.gz: e69ef13d0fc8014792dcf4a64dfd6757c33cd7dea1774bfbb54194f5cfacdb1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a992b442bdaf429cb43393330c3c65fc74fe8f60e4803939b74dc7911cb43d6411152563a0022859af4fa06daf1861b8b67bc12073249a471744d2c7e5bba0e
|
|
7
|
+
data.tar.gz: f4e38958524c13c1da10e6a20c6834b5840e2a6feeeac09d33c73766b1445e730eea79b9e7920ae39d9a9dec1e58e22ad6d2a21c2981d51a5850323906df2d54
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
+
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
10
|
+
|
|
3
11
|
## 0.1.0
|
|
4
12
|
|
|
5
13
|
First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gemstack-db
|
|
2
2
|
|
|
3
|
-
GemStack database: Sequel models
|
|
3
|
+
GemStack database: Sequel models and migrations for SQLite, PostgreSQL and MySQL.
|
|
4
4
|
|
|
5
5
|
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
6
|
applications. All GemStack gems are developed together in that repository and released with the same
|
|
@@ -16,7 +16,7 @@ gem "gemstack-db", "~> 0.1"
|
|
|
16
16
|
|
|
17
17
|
## Documentation
|
|
18
18
|
|
|
19
|
-
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/
|
|
19
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/database.md)
|
|
20
20
|
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
21
21
|
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
22
22
|
|
|
@@ -0,0 +1,148 @@
|
|
|
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 (DECISIONS D-062):
|
|
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
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module GemStack
|
|
4
4
|
module DB
|
|
5
|
-
# Gives Sequel
|
|
6
|
-
#
|
|
5
|
+
# Gives Sequel errors their HTTP meaning via GemStack::ErrorMapping (no
|
|
6
|
+
# dependency on the HTTP layer), for PostgreSQL, MySQL and SQLite:
|
|
7
7
|
#
|
|
8
8
|
# record not found → 404 not_found
|
|
9
9
|
# model validation failed → 422 validation_failed + field errors
|
|
10
|
-
# unique / not-null / FK → 422 with the offending column when
|
|
10
|
+
# unique / not-null / FK → 422 with the offending column when the database names it
|
|
11
11
|
# row still referenced (FK) → 409 conflict
|
|
12
12
|
# database unreachable → 503 service_unavailable
|
|
13
13
|
module Errors
|
|
14
|
-
KEY_DETAIL = /Key \(([^)]+)\)=/
|
|
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
|
|
15
18
|
|
|
16
19
|
module_function
|
|
17
20
|
|
|
18
21
|
def install!
|
|
22
|
+
# Registered first: the specific classes below take precedence.
|
|
23
|
+
ErrorMapping.register(Sequel::DatabaseError) { |error| mysql_missing_value(error) }
|
|
19
24
|
install_record_errors!
|
|
20
25
|
install_constraint_errors!
|
|
21
26
|
ErrorMapping.register(Sequel::DatabaseConnectionError) { ServiceUnavailable.new("Database unavailable") }
|
|
@@ -46,7 +51,7 @@ module GemStack
|
|
|
46
51
|
field_error([column(error)].compact, "is required") || ValidationError.new("A required value is missing")
|
|
47
52
|
end
|
|
48
53
|
ErrorMapping.register(Sequel::ForeignKeyConstraintViolation) do |error|
|
|
49
|
-
if
|
|
54
|
+
if referenced_missing?(error)
|
|
50
55
|
field_error(columns(error), "does not exist") || ValidationError.new("A referenced record does not exist")
|
|
51
56
|
else
|
|
52
57
|
Conflict.new("Record is still referenced by other records", code: "still_referenced")
|
|
@@ -57,6 +62,13 @@ module GemStack
|
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
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
|
+
|
|
60
72
|
def stringify(errors) = errors.to_h { |key, messages| [Array(key).join(","), Array(messages)] }
|
|
61
73
|
|
|
62
74
|
def field_error(names, message)
|
|
@@ -67,23 +79,57 @@ module GemStack
|
|
|
67
79
|
|
|
68
80
|
def pg_error(error) = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
|
|
69
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.
|
|
70
90
|
def columns(error)
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
[]
|
|
73
102
|
end
|
|
74
|
-
detail ||= error.message
|
|
75
|
-
match = KEY_DETAIL.match(detail.to_s)
|
|
76
|
-
match ? match[1].split(",").map(&:strip).map { |c| c.delete('"') } : []
|
|
77
103
|
rescue StandardError
|
|
78
104
|
[]
|
|
79
105
|
end
|
|
80
106
|
|
|
81
107
|
def column(error)
|
|
82
|
-
|
|
83
|
-
|
|
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)
|
|
84
112
|
rescue StandardError
|
|
85
113
|
nil
|
|
86
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
|
|
87
133
|
end
|
|
88
134
|
end
|
|
89
135
|
end
|
data/lib/gemstack/db/migrator.rb
CHANGED
data/lib/gemstack/db/model.rb
CHANGED
|
@@ -49,6 +49,7 @@ module GemStack
|
|
|
49
49
|
raise ArgumentError, "unknown field option(s) #{unknown.inspect} for #{name}" unless unknown.empty?
|
|
50
50
|
|
|
51
51
|
@input_schemas = nil
|
|
52
|
+
serialize_json(name) if Types::CLASS_ALIASES.fetch(type, type).to_sym == :json
|
|
52
53
|
gemstack_fields[name.to_sym] = Field.new(name.to_sym, Types::CLASS_ALIASES.fetch(type, type).to_sym,
|
|
53
54
|
options.freeze)
|
|
54
55
|
end
|
|
@@ -99,6 +100,15 @@ module GemStack
|
|
|
99
100
|
|
|
100
101
|
private
|
|
101
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
|
+
|
|
102
112
|
# For a table that doesn't exist yet, skip Sequel's schema queries: they
|
|
103
113
|
# would fail and log two errors per model (e.g. while `gemstack contract`
|
|
104
114
|
# runs before `db:migrate`). One cheap catalog lookup decides.
|
|
@@ -110,9 +120,9 @@ module GemStack
|
|
|
110
120
|
end
|
|
111
121
|
|
|
112
122
|
def missing_table?
|
|
113
|
-
return false unless @dataset
|
|
123
|
+
return false unless @dataset
|
|
114
124
|
|
|
115
|
-
|
|
125
|
+
DB.table_missing?(db, dataset.first_source_table)
|
|
116
126
|
rescue Sequel::Error
|
|
117
127
|
false
|
|
118
128
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
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 (DECISIONS D-062). 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
CHANGED
|
@@ -1,46 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module GemStack
|
|
6
6
|
module DB
|
|
7
|
-
# Database lifecycle used by `gemstack 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.
|
|
8
12
|
module Tasks
|
|
9
13
|
module_function
|
|
10
14
|
|
|
11
|
-
def
|
|
12
|
-
|
|
15
|
+
def settings_for(target)
|
|
16
|
+
target.is_a?(String) ? Configuration.from_url(target, root: GemStack.config.root) : target.except(:source)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
# The
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
uri.to_s
|
|
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}/")
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
def exists?(
|
|
23
|
-
|
|
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
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
# Returns :created or :exists.
|
|
27
|
-
def create(
|
|
28
|
-
|
|
36
|
+
def create(target = DB.settings)
|
|
37
|
+
settings = settings_for(target)
|
|
38
|
+
return :exists if exists?(settings)
|
|
29
39
|
|
|
30
|
-
|
|
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
|
|
31
53
|
:created
|
|
32
54
|
end
|
|
33
55
|
|
|
34
56
|
# Refuses outside development/test unless GEMSTACK_ALLOW_DB_DROP=1.
|
|
35
|
-
def drop(
|
|
57
|
+
def drop(target = DB.settings)
|
|
36
58
|
unless GemStack.env.local? || ENV["GEMSTACK_ALLOW_DB_DROP"] == "1"
|
|
37
59
|
raise Error, "refusing to drop the #{GemStack.env} database; set GEMSTACK_ALLOW_DB_DROP=1 to confirm"
|
|
38
60
|
end
|
|
39
|
-
|
|
61
|
+
|
|
62
|
+
settings = settings_for(target)
|
|
63
|
+
return :missing unless exists?(settings)
|
|
40
64
|
|
|
41
65
|
DB.disconnect
|
|
42
|
-
|
|
43
|
-
|
|
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))}") }
|
|
44
72
|
end
|
|
45
73
|
:dropped
|
|
46
74
|
end
|
|
@@ -52,8 +80,11 @@ module GemStack
|
|
|
52
80
|
true
|
|
53
81
|
end
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
57
88
|
yield db
|
|
58
89
|
ensure
|
|
59
90
|
db&.disconnect
|
data/lib/gemstack/db.rb
CHANGED
|
@@ -6,23 +6,24 @@ require "gemstack/core"
|
|
|
6
6
|
require "gemstack/schema"
|
|
7
7
|
|
|
8
8
|
require_relative "db/json_compat"
|
|
9
|
+
require_relative "db/configuration"
|
|
10
|
+
require_relative "db/schema_types"
|
|
9
11
|
|
|
10
12
|
module GemStack
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
+
# SQL databases through Sequel (DECISIONS D-018, D-062): 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.
|
|
13
17
|
#
|
|
14
18
|
# GemStack.db[:products].where(active: true).count # the Sequel::Database
|
|
15
19
|
# GemStack.transaction { order.save; payment.save }
|
|
16
20
|
module DB
|
|
17
21
|
class Config < Settings
|
|
18
|
-
#
|
|
19
|
-
# (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(env.test? ? ENV.fetch("TEST_DATABASE_URL", nil) : ENV.fetch("DATABASE_URL", nil)) || "postgres:///#{name}"
|
|
24
|
-
}
|
|
25
|
-
# One connection per server thread by default.
|
|
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).
|
|
26
27
|
setting :pool_size, default: lambda {
|
|
27
28
|
Integer(ENV.fetch("GEMSTACK_DB_POOL") { ENV.fetch("GEMSTACK_MAX_THREADS", 5) })
|
|
28
29
|
}
|
|
@@ -32,21 +33,27 @@ module GemStack
|
|
|
32
33
|
setting :log_queries, default: -> { GemStack.env.development? }
|
|
33
34
|
# Queries slower than this are logged at WARN in every environment.
|
|
34
35
|
setting :slow_query_ms, default: 500
|
|
35
|
-
# Server-side statement timeout in ms (nil =
|
|
36
|
+
# Server-side statement timeout in ms (nil = the database's default).
|
|
37
|
+
# PostgreSQL: statement_timeout; MySQL: max_execution_time (SELECTs); SQLite: none.
|
|
36
38
|
setting :statement_timeout, default: nil
|
|
37
|
-
|
|
39
|
+
# Sequel extensions to load; nil = pg_json + pg_array on PostgreSQL, none elsewhere.
|
|
40
|
+
setting :extensions, default: nil
|
|
38
41
|
# Extra options passed to Sequel.connect.
|
|
39
42
|
setting :options, default: {}
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
class RecordNotFound < NotFound; end
|
|
43
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
|
+
|
|
44
51
|
# Adapts Sequel's logging to the GemStack logger: SQL at DEBUG only when
|
|
45
52
|
# config.db.log_queries is on, slow queries at WARN, and failures of the
|
|
46
53
|
# existence probes Sequel runs on purpose (table_exists?, model setup
|
|
47
54
|
# before a migration) are not reported as errors.
|
|
48
55
|
class QueryLogger
|
|
49
|
-
PROBE = /: SELECT (?:NULL AS "nil" FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
|
|
56
|
+
PROBE = /: SELECT (?:NULL AS [`"']nil[`"'] FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
|
|
50
57
|
|
|
51
58
|
def initialize(logger, queries:)
|
|
52
59
|
@logger = logger
|
|
@@ -70,18 +77,34 @@ module GemStack
|
|
|
70
77
|
|
|
71
78
|
def connected? = !@connection.nil?
|
|
72
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
|
+
|
|
73
98
|
# Builds the Sequel::Database. Connections are opened lazily, so an
|
|
74
99
|
# unavailable database doesn't prevent the application from booting.
|
|
75
|
-
def connect(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
db.
|
|
82
|
-
db
|
|
83
|
-
db.loggers << QueryLogger.new(GemStack.logger, queries: config.log_queries)
|
|
84
|
-
db.sql_log_level = :debug
|
|
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)
|
|
85
108
|
Sequel::Model.db = db
|
|
86
109
|
db
|
|
87
110
|
end
|
|
@@ -94,6 +117,60 @@ module GemStack
|
|
|
94
117
|
end
|
|
95
118
|
|
|
96
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
|
|
97
174
|
end
|
|
98
175
|
end
|
|
99
176
|
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shoaib Malik
|
|
@@ -15,42 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.1
|
|
18
|
+
version: 0.2.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.1
|
|
25
|
+
version: 0.2.1
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: gemstack-schema
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - '='
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.1
|
|
32
|
+
version: 0.2.1
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - '='
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.1
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: pg
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - ">="
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '1.5'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '1.5'
|
|
39
|
+
version: 0.2.1
|
|
54
40
|
- !ruby/object:Gem::Dependency
|
|
55
41
|
name: sequel
|
|
56
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -75,10 +61,12 @@ files:
|
|
|
75
61
|
- LICENSE.txt
|
|
76
62
|
- README.md
|
|
77
63
|
- lib/gemstack/db.rb
|
|
64
|
+
- lib/gemstack/db/configuration.rb
|
|
78
65
|
- lib/gemstack/db/errors.rb
|
|
79
66
|
- lib/gemstack/db/json_compat.rb
|
|
80
67
|
- lib/gemstack/db/migrator.rb
|
|
81
68
|
- lib/gemstack/db/model.rb
|
|
69
|
+
- lib/gemstack/db/schema_types.rb
|
|
82
70
|
- lib/gemstack/db/tasks.rb
|
|
83
71
|
- lib/gemstack/db/testing.rb
|
|
84
72
|
homepage: https://github.com/gemstack-rb/gemstack
|
|
@@ -106,5 +94,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
106
94
|
requirements: []
|
|
107
95
|
rubygems_version: 4.0.20
|
|
108
96
|
specification_version: 4
|
|
109
|
-
summary: 'GemStack database: Sequel models
|
|
97
|
+
summary: 'GemStack database: Sequel models and migrations for SQLite, PostgreSQL and
|
|
98
|
+
MySQL'
|
|
110
99
|
test_files: []
|