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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21dec8b41b0440c61fedf33b9d516672a31000feede8bb3a51c04bf6b635df69
4
- data.tar.gz: 837a7cb9b46567af652473bbcf6370e72a0497d9b86152eab4bc7a58f7895acb
3
+ metadata.gz: 47396d58ea87b22d25ad2fd79a425224b1f2bd6709bbc9186fd58e7e6e0f8a49
4
+ data.tar.gz: 0c62733861805c0f951033fa2ce5e256874793d3129edddaf53ced3cb1cc12fb
5
5
  SHA512:
6
- metadata.gz: 6f67b3804ec3a0e0d8866b834f98f823770b49cfd1ba24a73d2e39036116ae07eab309e3151f7d5232fc76472115357a1a4222e8633f6a68f4100760813841c1
7
- data.tar.gz: 9e13d2708d4467477cf2e189f86d355fa1d2dafab6fb7b431a6d1c86b8c746fcf98c2db01e3459f9cfcd4ba0a1adf7e65be720ba8643ba777c875f644c5ccde6
6
+ metadata.gz: b99bd922ddc122d9b7cb74e0bb73b0874711ae53d63f589c4d5598f81e2c6a2631624f4773211f42ca8ccfa0c7aba6a4e2f20bd254604c085e3bd6658a09d3e1
7
+ data.tar.gz: 6be0513b6cbf84eb30fd80c2f61e6dda3a7cff5f1bcfed84fe37b7d45d7f9ae1e024cbb5094ab8e7642b1bd36ca72e1507ec04b823c4221aa8da833334c03463
data/CHANGELOG.md CHANGED
@@ -1,29 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.5
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
- GemStack database: Sequel models and migrations for SQLite, PostgreSQL and MySQL.
3
+ **Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
4
4
 
5
- Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
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
- ## Installation
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
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # gemstack-db is part of the gemstack gem since 0.3.0. This shim keeps Gemfiles
4
+ # that still list gemstack-db working; remove the line from your Gemfile.
5
+ require "gemstack/db"
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.2.5
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-core
14
+ name: gemstack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.2.5
20
- type: :runtime
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.2.5
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.2.5
41
- - !ruby/object:Gem::Dependency
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: '5.80'
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/db.rb
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/tree/main/gems/gemstack-db
79
- changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-db/CHANGELOG.md
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: 'GemStack database: Sequel models and migrations for SQLite, PostgreSQL and
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
@@ -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!
@@ -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
@@ -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)
@@ -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
@@ -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