gemstack-db 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gemstack/db/errors.rb +91 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +216 -0
- data/lib/gemstack/db/tasks.rb +63 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +133 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4dfcb9cab762647a4a5b66a1b39a171654b2e90a4fa5fb51ce582a92866ec610
|
|
4
|
+
data.tar.gz: 11711623cf668ec45e2fc92e046e7fe8cf8592654e13cbbb334609bcbd103e51
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 50f2dbc4310478ffc79f2239b0dcc630c169341be13a8f0189a8b977e74e226412c78be563cca57f0de862c7c31dde12169f6e14ce86e97f44bd65fb78e199b6
|
|
7
|
+
data.tar.gz: 64c0e1055cef2f98c087daf97f9a5890bf6aaf353ff6d3cf33f446fedaa6000fe59079dcd27f070b1cbe6a37f853682ad8419293a18dd7d19d50a2bcf9b2a4ff
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# gemstack-db
|
|
2
|
+
|
|
3
|
+
GemStack database: Sequel models, migrations and PostgreSQL defaults.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
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/models.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`).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module DB
|
|
5
|
+
# Gives Sequel/PostgreSQL errors their HTTP meaning via GemStack::ErrorMapping
|
|
6
|
+
# (no dependency on the HTTP layer):
|
|
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 PostgreSQL reports it
|
|
11
|
+
# row still referenced (FK) → 409 conflict
|
|
12
|
+
# database unreachable → 503 service_unavailable
|
|
13
|
+
module Errors
|
|
14
|
+
KEY_DETAIL = /Key \(([^)]+)\)=/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def install!
|
|
19
|
+
install_record_errors!
|
|
20
|
+
install_constraint_errors!
|
|
21
|
+
ErrorMapping.register(Sequel::DatabaseConnectionError) { ServiceUnavailable.new("Database unavailable") }
|
|
22
|
+
ErrorMapping.register(Sequel::PoolTimeout) { ServiceUnavailable.new("Database busy, try again") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def install_record_errors!
|
|
26
|
+
ErrorMapping.register(Sequel::NoMatchingRow) do |error|
|
|
27
|
+
model = error.respond_to?(:dataset) && error.dataset.respond_to?(:model) ? error.dataset.model : nil
|
|
28
|
+
RecordNotFound.new(model&.name ? "#{model.name} not found" : "Record not found")
|
|
29
|
+
end
|
|
30
|
+
ErrorMapping.register(Sequel::ValidationFailed) do |error|
|
|
31
|
+
ValidationError.new(errors: stringify(error.errors))
|
|
32
|
+
end
|
|
33
|
+
ErrorMapping.register(Sequel::InvalidValue) do |error|
|
|
34
|
+
ValidationError.new(error.message.sub(/\A.*?: /, "Invalid value: "), code: "invalid_value")
|
|
35
|
+
end
|
|
36
|
+
ErrorMapping.register(Sequel::MassAssignmentRestriction) do |error|
|
|
37
|
+
BadRequest.new(error.message, code: "unknown_attribute")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def install_constraint_errors!
|
|
42
|
+
ErrorMapping.register(Sequel::UniqueConstraintViolation) do |error|
|
|
43
|
+
field_error(columns(error), "is already taken") || Conflict.new("Record already exists")
|
|
44
|
+
end
|
|
45
|
+
ErrorMapping.register(Sequel::NotNullConstraintViolation) do |error|
|
|
46
|
+
field_error([column(error)].compact, "is required") || ValidationError.new("A required value is missing")
|
|
47
|
+
end
|
|
48
|
+
ErrorMapping.register(Sequel::ForeignKeyConstraintViolation) do |error|
|
|
49
|
+
if error.message.include?("is not present")
|
|
50
|
+
field_error(columns(error), "does not exist") || ValidationError.new("A referenced record does not exist")
|
|
51
|
+
else
|
|
52
|
+
Conflict.new("Record is still referenced by other records", code: "still_referenced")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
ErrorMapping.register(Sequel::CheckConstraintViolation) do
|
|
56
|
+
ValidationError.new("A value violates a database constraint", code: "constraint_violation")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stringify(errors) = errors.to_h { |key, messages| [Array(key).join(","), Array(messages)] }
|
|
61
|
+
|
|
62
|
+
def field_error(names, message)
|
|
63
|
+
return nil if names.empty?
|
|
64
|
+
|
|
65
|
+
ValidationError.new(errors: names.to_h { |name| [name, [message]] })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pg_error(error) = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
|
|
69
|
+
|
|
70
|
+
def columns(error)
|
|
71
|
+
if defined?(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
72
|
+
detail = pg_error(error)&.result&.error_field(PG::PG_DIAG_MESSAGE_DETAIL)
|
|
73
|
+
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
|
+
rescue StandardError
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def column(error)
|
|
82
|
+
pg_error(error)&.result&.error_field(PG::PG_DIAG_COLUMN_NAME) ||
|
|
83
|
+
error.message[/column "([^"]+)"/, 1]
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
GemStack::DB::Errors.install!
|
|
@@ -0,0 +1,21 @@
|
|
|
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 (DECISIONS D-041).
|
|
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!
|
|
@@ -0,0 +1,80 @@
|
|
|
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 [] unless db.table_exists?(: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
|
|
@@ -0,0 +1,216 @@
|
|
|
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" (DECISIONS D-024).
|
|
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
|
+
gemstack_fields[name.to_sym] = Field.new(name.to_sym, Types::CLASS_ALIASES.fetch(type, type).to_sym,
|
|
53
|
+
options.freeze)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def gemstack_validations
|
|
57
|
+
@gemstack_validations ||=
|
|
58
|
+
superclass.respond_to?(:gemstack_validations) ? superclass.gemstack_validations.dup : []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# validates :name, :sku, presence: true, length: { max: 40 }, format: /\A[A-Z]/,
|
|
62
|
+
# inclusion: { in: %w[a b] }, numericality: { gt: 0 }, uniqueness: true
|
|
63
|
+
def validates(*names, **rules)
|
|
64
|
+
unknown = rules.keys - %i[presence length format inclusion numericality uniqueness]
|
|
65
|
+
raise ArgumentError, "unknown validation(s) #{unknown.inspect}" unless unknown.empty?
|
|
66
|
+
|
|
67
|
+
gemstack_validations << [names.map(&:to_sym), rules]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A request schema derived from the field declarations (memoized):
|
|
71
|
+
# accepts :create, with: Product.input_schema
|
|
72
|
+
def input_schema(only: nil, except: nil)
|
|
73
|
+
key = [only, except]
|
|
74
|
+
(@input_schemas ||= {})[key] ||= Schema.from_model(
|
|
75
|
+
self, only: only, except: except, type_name: "#{name.to_s.split("::").join}Input"
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# find(42) raises GemStack::DB::RecordNotFound (404) when missing.
|
|
80
|
+
# With a Hash or block it behaves like Sequel's find (nil when missing).
|
|
81
|
+
def find(*args, &block)
|
|
82
|
+
return super if block || args.size != 1 || args.first.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
id = args.first
|
|
85
|
+
record = valid_primary_key?(id) ? with_pk(id) : nil
|
|
86
|
+
record || raise(DB::RecordNotFound, "#{name} #{id} not found")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_by(conditions) = first(conditions)
|
|
90
|
+
def find_by!(conditions) = first(conditions) || raise(DB::RecordNotFound, "#{name} not found")
|
|
91
|
+
|
|
92
|
+
alias create! create
|
|
93
|
+
|
|
94
|
+
# Associations with familiar names; the Sequel names work too.
|
|
95
|
+
def belongs_to(name, **) = many_to_one(name, **)
|
|
96
|
+
def has_many(name, **) = one_to_many(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
97
|
+
def has_one(name, **) = one_to_one(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
98
|
+
def has_and_belongs_to_many(name, **) = many_to_many(name, **) # rubocop:disable Naming/PredicatePrefix
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# For a table that doesn't exist yet, skip Sequel's schema queries: they
|
|
103
|
+
# would fail and log two errors per model (e.g. while `gemstack contract`
|
|
104
|
+
# runs before `db:migrate`). One cheap catalog lookup decides.
|
|
105
|
+
def get_db_schema(reload = reload_db_schema?)
|
|
106
|
+
return super unless missing_table?
|
|
107
|
+
|
|
108
|
+
set_columns(nil)
|
|
109
|
+
{}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def missing_table?
|
|
113
|
+
return false unless @dataset && db.database_type == :postgres
|
|
114
|
+
|
|
115
|
+
db.get(Sequel.function(:to_regclass, db.literal(dataset.first_source_table))).nil?
|
|
116
|
+
rescue Sequel::Error
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def valid_primary_key?(id)
|
|
121
|
+
return true unless integer_primary_key?
|
|
122
|
+
|
|
123
|
+
id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A\d{1,19}\z/))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def integer_primary_key?
|
|
127
|
+
return @integer_primary_key if defined?(@integer_primary_key)
|
|
128
|
+
|
|
129
|
+
@integer_primary_key = primary_key.is_a?(Symbol) && db_schema.dig(primary_key, :type) == :integer
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
alias update! update
|
|
134
|
+
|
|
135
|
+
# Identifies this version of the record, for ETags (Controller#stale?) and
|
|
136
|
+
# cache keys: "product/42-1759052159.123456".
|
|
137
|
+
def cache_key
|
|
138
|
+
stamp = respond_to?(:updated_at) && updated_at ? "-#{updated_at.to_f}" : ""
|
|
139
|
+
"#{Inflector.underscore(self.class.name.to_s)}/#{pk}#{stamp}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate
|
|
143
|
+
super
|
|
144
|
+
validate_fields
|
|
145
|
+
self.class.gemstack_validations.each { |names, rules| apply_validations(names, rules) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Models are never rendered implicitly: exposing every column is how
|
|
149
|
+
# APIs leak data. Define a serializer instead.
|
|
150
|
+
def as_json(*)
|
|
151
|
+
file = "#{Inflector.underscore(self.class.name)}_serializer.rb"
|
|
152
|
+
raise Error, "#{self.class.name} has no serializer. Create #{file} " \
|
|
153
|
+
"in app/serializers (class #{self.class.name}Serializer < GemStack::Serializer)."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def to_json(*) = as_json
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def validate_fields
|
|
161
|
+
self.class.gemstack_fields.each_value do |field|
|
|
162
|
+
opts = field.options
|
|
163
|
+
name = field.name
|
|
164
|
+
if opts[:null] == false && !opts.key?(:default)
|
|
165
|
+
if field.type == :boolean
|
|
166
|
+
validates_includes([true, false], name, message: "must be true or false")
|
|
167
|
+
else
|
|
168
|
+
validates_presence(name, message: "is required")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
apply_rules(name, opts)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def apply_rules(name, opts)
|
|
176
|
+
if opts[:size].is_a?(Integer)
|
|
177
|
+
validates_max_length(opts[:size], name, message: "is too long (maximum #{opts[:size]} characters)",
|
|
178
|
+
allow_nil: true)
|
|
179
|
+
end
|
|
180
|
+
{ gt: :>, gte: :>=, lt: :<, lte: :<= }.each do |key, operator|
|
|
181
|
+
next unless opts.key?(key)
|
|
182
|
+
|
|
183
|
+
words = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
|
|
184
|
+
validates_operator(operator, opts[key], name, message: "must be #{words[key]} #{opts[key]}", allow_nil: true)
|
|
185
|
+
end
|
|
186
|
+
if opts[:in]
|
|
187
|
+
validates_includes(opts[:in], name, message: "must be one of: #{opts[:in].to_a.join(", ")}",
|
|
188
|
+
allow_nil: true)
|
|
189
|
+
end
|
|
190
|
+
validates_format(opts[:format], name, message: "is invalid", allow_nil: true) if opts[:format]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def apply_validations(names, rules)
|
|
194
|
+
names.each do |name|
|
|
195
|
+
validates_presence(name, message: "is required") if rules[:presence]
|
|
196
|
+
length = rules[:length]
|
|
197
|
+
if length
|
|
198
|
+
apply_rules(name, size: length[:max]) if length[:max]
|
|
199
|
+
if length[:min]
|
|
200
|
+
validates_min_length(length[:min], name, message: "is too short (minimum #{length[:min]} characters)",
|
|
201
|
+
allow_nil: true)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
apply_rules(name, format: rules[:format]) if rules[:format]
|
|
205
|
+
inclusion = rules[:inclusion]
|
|
206
|
+
apply_rules(name, in: inclusion.is_a?(Hash) ? inclusion[:in] : inclusion) if inclusion
|
|
207
|
+
numeric = rules[:numericality]
|
|
208
|
+
if numeric
|
|
209
|
+
validates_numeric(name, message: "must be a number", allow_nil: true)
|
|
210
|
+
apply_rules(name, numeric.slice(:gt, :gte, :lt, :lte)) if numeric.is_a?(Hash)
|
|
211
|
+
end
|
|
212
|
+
validates_unique(name, message: "is already taken") if rules[:uniqueness]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module DB
|
|
7
|
+
# Database lifecycle used by `gemstack db:*`.
|
|
8
|
+
module Tasks
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def database_name(url = DB.config.url)
|
|
12
|
+
URI.parse(url).path.delete_prefix("/").then { |n| URI.decode_www_form_component(n) }
|
|
13
|
+
end
|
|
14
|
+
|
|
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
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?(url = DB.config.url)
|
|
23
|
+
with_maintenance(url) { |db| db[:pg_database].where(datname: database_name(url)).any? }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns :created or :exists.
|
|
27
|
+
def create(url = DB.config.url)
|
|
28
|
+
return :exists if exists?(url)
|
|
29
|
+
|
|
30
|
+
with_maintenance(url) { |db| db.run("CREATE DATABASE #{db.literal(Sequel.identifier(database_name(url)))}") }
|
|
31
|
+
:created
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Refuses outside development/test unless GEMSTACK_ALLOW_DB_DROP=1.
|
|
35
|
+
def drop(url = DB.config.url)
|
|
36
|
+
unless GemStack.env.local? || ENV["GEMSTACK_ALLOW_DB_DROP"] == "1"
|
|
37
|
+
raise Error, "refusing to drop the #{GemStack.env} database; set GEMSTACK_ALLOW_DB_DROP=1 to confirm"
|
|
38
|
+
end
|
|
39
|
+
return :missing unless exists?(url)
|
|
40
|
+
|
|
41
|
+
DB.disconnect
|
|
42
|
+
with_maintenance(url) do |db|
|
|
43
|
+
db.run("DROP DATABASE #{db.literal(Sequel.identifier(database_name(url)))} WITH (FORCE)")
|
|
44
|
+
end
|
|
45
|
+
:dropped
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def seed(path = GemStack.root.join(DB.config.seeds_path))
|
|
49
|
+
return false unless File.file?(path)
|
|
50
|
+
|
|
51
|
+
load path.to_s
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with_maintenance(url)
|
|
56
|
+
db = Sequel.connect(maintenance_url(url), max_connections: 1, test: true, keep_reference: false)
|
|
57
|
+
yield db
|
|
58
|
+
ensure
|
|
59
|
+
db&.disconnect
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
|
|
10
|
+
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
|
+
#
|
|
14
|
+
# GemStack.db[:products].where(active: true).count # the Sequel::Database
|
|
15
|
+
# GemStack.transaction { order.save; payment.save }
|
|
16
|
+
module DB
|
|
17
|
+
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.
|
|
26
|
+
setting :pool_size, default: lambda {
|
|
27
|
+
Integer(ENV.fetch("GEMSTACK_DB_POOL") { ENV.fetch("GEMSTACK_MAX_THREADS", 5) })
|
|
28
|
+
}
|
|
29
|
+
setting :pool_timeout, default: 5
|
|
30
|
+
setting :migrations_path, default: "db/migrations"
|
|
31
|
+
setting :seeds_path, default: "db/seeds.rb"
|
|
32
|
+
setting :log_queries, default: -> { GemStack.env.development? }
|
|
33
|
+
# Queries slower than this are logged at WARN in every environment.
|
|
34
|
+
setting :slow_query_ms, default: 500
|
|
35
|
+
# Server-side statement timeout in ms (nil = PostgreSQL default).
|
|
36
|
+
setting :statement_timeout, default: nil
|
|
37
|
+
setting :extensions, default: %i[pg_json pg_array]
|
|
38
|
+
# Extra options passed to Sequel.connect.
|
|
39
|
+
setting :options, default: {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class RecordNotFound < NotFound; end
|
|
43
|
+
|
|
44
|
+
# Adapts Sequel's logging to the GemStack logger: SQL at DEBUG only when
|
|
45
|
+
# config.db.log_queries is on, slow queries at WARN, and failures of the
|
|
46
|
+
# existence probes Sequel runs on purpose (table_exists?, model setup
|
|
47
|
+
# before a migration) are not reported as errors.
|
|
48
|
+
class QueryLogger
|
|
49
|
+
PROBE = /: SELECT (?:NULL AS "nil" FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
|
|
50
|
+
|
|
51
|
+
def initialize(logger, queries:)
|
|
52
|
+
@logger = logger
|
|
53
|
+
@queries = queries
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def debug(message) = (@logger.debug(message) if @queries)
|
|
57
|
+
def info(message) = @logger.info(message)
|
|
58
|
+
def warn(message) = @logger.warn(message)
|
|
59
|
+
def error(message) = (@logger.error(message) unless PROBE.match?(message.to_s))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@mutex = Mutex.new
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
def config = GemStack.config.db
|
|
66
|
+
|
|
67
|
+
def connection
|
|
68
|
+
@connection || @mutex.synchronize { @connection ||= connect }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def connected? = !@connection.nil?
|
|
72
|
+
|
|
73
|
+
# Builds the Sequel::Database. Connections are opened lazily, so an
|
|
74
|
+
# 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
|
|
85
|
+
Sequel::Model.db = db
|
|
86
|
+
db
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Closes pooled connections (e.g. before Puma forks workers). The
|
|
90
|
+
# Database object stays in place and reconnects lazily: models hold a
|
|
91
|
+
# reference to it, so replacing it would split them from GemStack.db.
|
|
92
|
+
def disconnect
|
|
93
|
+
@connection&.disconnect
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def transaction(**, &) = connection.transaction(**, &)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A model base class bound to an explicit table, like Sequel::Model(:table):
|
|
101
|
+
# class Item < GemStack::Model(:inventory_items)
|
|
102
|
+
def self.Model(source) = Model.Model(source) # rubocop:disable Naming/MethodName
|
|
103
|
+
|
|
104
|
+
class << self
|
|
105
|
+
def db = DB.connection
|
|
106
|
+
def transaction(**, &) = DB.transaction(**, &)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
require_relative "db/model"
|
|
111
|
+
require_relative "db/errors"
|
|
112
|
+
require_relative "db/migrator"
|
|
113
|
+
require_relative "db/tasks"
|
|
114
|
+
|
|
115
|
+
GemStack::Config.namespace(:db, GemStack::DB::Config)
|
|
116
|
+
|
|
117
|
+
GemStack::Plugins.register(:db) do |app|
|
|
118
|
+
GemStack::DB.connection
|
|
119
|
+
app.on_shutdown { GemStack::DB.disconnect } if app.respond_to?(:on_shutdown)
|
|
120
|
+
|
|
121
|
+
# A friendly nudge in development; never blocks boot.
|
|
122
|
+
if GemStack.env.development?
|
|
123
|
+
begin
|
|
124
|
+
pending = GemStack::DB::Migrator.new.pending
|
|
125
|
+
unless pending.empty?
|
|
126
|
+
GemStack.logger.warn("#{pending.size} pending migration(s) — run `gemstack db:migrate`",
|
|
127
|
+
first: pending.first.file)
|
|
128
|
+
end
|
|
129
|
+
rescue Sequel::Error
|
|
130
|
+
nil # database not reachable yet; requests will report it
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-db
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: gemstack-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: gemstack-schema
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - '='
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - '='
|
|
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'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: sequel
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.80'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.80'
|
|
68
|
+
email:
|
|
69
|
+
- gemstack26@gmail.com
|
|
70
|
+
executables: []
|
|
71
|
+
extensions: []
|
|
72
|
+
extra_rdoc_files: []
|
|
73
|
+
files:
|
|
74
|
+
- CHANGELOG.md
|
|
75
|
+
- LICENSE.txt
|
|
76
|
+
- README.md
|
|
77
|
+
- lib/gemstack/db.rb
|
|
78
|
+
- lib/gemstack/db/errors.rb
|
|
79
|
+
- lib/gemstack/db/json_compat.rb
|
|
80
|
+
- lib/gemstack/db/migrator.rb
|
|
81
|
+
- lib/gemstack/db/model.rb
|
|
82
|
+
- lib/gemstack/db/tasks.rb
|
|
83
|
+
- lib/gemstack/db/testing.rb
|
|
84
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
rubygems_mfa_required: 'true'
|
|
89
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-db
|
|
90
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-db/CHANGELOG.md
|
|
91
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
92
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '4.0'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 4.0.20
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: 'GemStack database: Sequel models, migrations and PostgreSQL defaults'
|
|
110
|
+
test_files: []
|