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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
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
@@ -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: []