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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. 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