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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4dfcb9cab762647a4a5b66a1b39a171654b2e90a4fa5fb51ce582a92866ec610
4
- data.tar.gz: 11711623cf668ec45e2fc92e046e7fe8cf8592654e13cbbb334609bcbd103e51
3
+ metadata.gz: cf134933c7903511b62c753226076a98b1ae0d927167ae8007d32f9afb6abc27
4
+ data.tar.gz: e69ef13d0fc8014792dcf4a64dfd6757c33cd7dea1774bfbb54194f5cfacdb1d
5
5
  SHA512:
6
- metadata.gz: 50f2dbc4310478ffc79f2239b0dcc630c169341be13a8f0189a8b977e74e226412c78be563cca57f0de862c7c31dde12169f6e14ce86e97f44bd65fb78e199b6
7
- data.tar.gz: 64c0e1055cef2f98c087daf97f9a5890bf6aaf353ff6d3cf33f446fedaa6000fe59079dcd27f070b1cbe6a37f853682ad8419293a18dd7d19d50a2bcf9b2a4ff
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, migrations and PostgreSQL defaults.
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/models.md)
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
@@ -2,20 +2,25 @@
2
2
 
3
3
  module GemStack
4
4
  module DB
5
- # Gives Sequel/PostgreSQL errors their HTTP meaning via GemStack::ErrorMapping
6
- # (no dependency on the HTTP layer):
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 PostgreSQL reports it
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 error.message.include?("is not present")
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
- if defined?(PG::PG_DIAG_MESSAGE_DETAIL)
72
- detail = pg_error(error)&.result&.error_field(PG::PG_DIAG_MESSAGE_DETAIL)
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
- pg_error(error)&.result&.error_field(PG::PG_DIAG_COLUMN_NAME) ||
83
- error.message[/column "([^"]+)"/, 1]
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
@@ -61,7 +61,7 @@ module GemStack
61
61
  def pending? = !pending.empty?
62
62
 
63
63
  def applied
64
- return [] unless db.table_exists?(:schema_migrations)
64
+ return [] if DB.table_missing?(db, :schema_migrations)
65
65
 
66
66
  db[:schema_migrations].select_map(:filename)
67
67
  end
@@ -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 && db.database_type == :postgres
123
+ return false unless @dataset
114
124
 
115
- db.get(Sequel.function(:to_regclass, db.literal(dataset.first_source_table))).nil?
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)
@@ -1,46 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
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 database_name(url = DB.config.url)
12
- URI.parse(url).path.delete_prefix("/").then { |n| URI.decode_www_form_component(n) }
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 same server with the "postgres" maintenance database.
16
- def maintenance_url(url = DB.config.url)
17
- uri = URI.parse(url)
18
- uri.path = "/postgres"
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?(url = DB.config.url)
23
- with_maintenance(url) { |db| db[:pg_database].where(datname: database_name(url)).any? }
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(url = DB.config.url)
28
- return :exists if exists?(url)
36
+ def create(target = DB.settings)
37
+ settings = settings_for(target)
38
+ return :exists if exists?(settings)
29
39
 
30
- with_maintenance(url) { |db| db.run("CREATE DATABASE #{db.literal(Sequel.identifier(database_name(url)))}") }
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(url = DB.config.url)
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
- return :missing unless exists?(url)
61
+
62
+ settings = settings_for(target)
63
+ return :missing unless exists?(settings)
40
64
 
41
65
  DB.disconnect
42
- with_maintenance(url) do |db|
43
- db.run("DROP DATABASE #{db.literal(Sequel.identifier(database_name(url)))} WITH (FORCE)")
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
- def with_maintenance(url)
56
- db = Sequel.connect(maintenance_url(url), max_connections: 1, test: true, keep_reference: false)
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
- # PostgreSQL through Sequel (DECISIONS D-018). Optional: add
12
- # `gem "gemstack-db"` to the Gemfile (new apps include it by default).
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
- # Test uses TEST_DATABASE_URL so a DATABASE_URL meant for development
19
- # (e.g. from .env) can never be used by the test suite.
20
- setting :url, default: lambda {
21
- env = GemStack.env
22
- name = "#{GemStack.config.name.tr("-", "_")}_#{env}"
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 = PostgreSQL default).
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
- setting :extensions, default: %i[pg_json pg_array]
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(url = config.url)
76
- options = { max_connections: config.pool_size, pool_timeout: config.pool_timeout, test: false,
77
- keep_reference: false }
78
- timeout = config.statement_timeout
79
- options[:after_connect] = ->(conn) { conn.exec("SET statement_timeout = #{Integer(timeout)}") } if timeout
80
- db = Sequel.connect(url, **options, **config.options)
81
- db.extension(*config.extensions) unless config.extensions.empty?
82
- db.log_warn_duration = config.slow_query_ms / 1000.0 if config.slow_query_ms
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.0
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.0
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.0
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.0
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.0
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, migrations and PostgreSQL defaults'
97
+ summary: 'GemStack database: Sequel models and migrations for SQLite, PostgreSQL and
98
+ MySQL'
110
99
  test_files: []