gemstack 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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -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.
|
|
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 [] 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
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module DB
|
|
5
|
+
# Migrations may use PostgreSQL's type names — the generators write
|
|
6
|
+
# `column :created_at, :timestamptz` — and still run on MySQL and SQLite,
|
|
7
|
+
# where each maps to the closest native type. 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)
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
|
@@ -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
|