mk_framework 0.2.0 → 0.2.2

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.
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'optparse'
5
+
6
+ module MK
7
+ module Generator
8
+ class CLI
9
+ class Cancelled < StandardError; end
10
+
11
+ def self.run(argv = ARGV, input: $stdin, output: $stdout)
12
+ new(input: input, output: output).run(argv)
13
+ end
14
+
15
+ def initialize(input:, output:)
16
+ @input, @output = input, output
17
+ end
18
+
19
+ def run(argv)
20
+ arguments = argv.dup
21
+ inline = nil
22
+ help = false
23
+ parser = OptionParser.new do |options|
24
+ options.banner = 'Usage: mk_frame_init [DESTINATION] [--cli SPEC]'
25
+ options.on('--cli SPEC', 'Generate without prompts: app_name:blog, model_name:posts, fields:[title:string]') { |value| inline = value }
26
+ options.on('-h', '--help', 'Show usage') { help = true }
27
+ end
28
+ parser.parse!(arguments)
29
+ if help
30
+ @output.puts parser
31
+ @output.puts 'Types: ' + Configuration::TYPES.keys.join(', ')
32
+ return 0
33
+ end
34
+ raise InvalidInput, parser.banner if arguments.length > 1
35
+ if inline
36
+ config = Options.parse(inline)
37
+ return generate(config, arguments.first || config.app_name)
38
+ end
39
+
40
+ @output.puts 'MK app generator — one model and a full CRUD resource with controllers and handlers.'
41
+ @output.puts 'Press Ctrl-C to cancel. All fields are required; id and timestamps are automatic.'
42
+ app = validated('1. App name (snake_case)') { |value| Configuration.app_name!(value) }
43
+ model = validated('2. Model name (singular snake_case)') { |value| Configuration.model_name!(value) }
44
+ resource = validated('3. Resource/table name (plural snake_case)', default: Naming.plural(model)) do |value|
45
+ Configuration.identifier!(value, label: 'Resource name')
46
+ end
47
+ fields = collect_fields
48
+ config = Configuration.new(app_name: app, model_name: model, resource: resource, fields: fields)
49
+ destination = File.expand_path(arguments.first || app)
50
+ @output.puts "\nApp: #{config.namespace}; model: #{config.model_class}; resource: /#{resource} (index, show, create, update, delete)"
51
+ @output.puts "Fields: #{fields.map { |field| "#{field[:name]}:#{field[:type]}" }.join(', ')}"
52
+ @output.puts "Directory: #{destination}"
53
+ answer = validated('5. Generate these files? (y/n)', default: 'y') do |value|
54
+ raise InvalidInput, 'Enter y or n.' unless %w[y n yes no].include?(value.downcase)
55
+ value.downcase
56
+ end
57
+ raise Cancelled if %w[n no].include?(answer)
58
+ generate(config, destination)
59
+ rescue Cancelled, Interrupt
60
+ @output.puts "\nCancelled."
61
+ 1
62
+ rescue InvalidInput, OptionParser::ParseError, SystemCallError => error
63
+ @output.puts "Error: #{error.message}"
64
+ 1
65
+ end
66
+
67
+ private
68
+
69
+ def generate(config, destination)
70
+ destination = Project.new(config).generate(destination)
71
+ @output.puts "\nCreated #{destination}\nNext steps:"
72
+ @output.puts " cd #{Shellwords.escape(destination)}"
73
+ @output.puts " bundle install\n bundle exec rake db:migrate\n bundle exec rake routes\n bundle exec rspec\n bundle exec rake"
74
+ 0
75
+ end
76
+
77
+ def ask(prompt, default: nil)
78
+ @output.print "#{prompt}#{default ? " [#{default}]" : ''}: "
79
+ @output.flush
80
+ line = @input.gets
81
+ raise Cancelled unless line
82
+ value = line.strip
83
+ value.empty? && default ? default : value
84
+ end
85
+
86
+ def validated(prompt, default: nil)
87
+ loop do
88
+ value = ask(prompt, default: default)
89
+ begin
90
+ return yield(value)
91
+ rescue InvalidInput => error
92
+ @output.puts error.message
93
+ end
94
+ end
95
+ end
96
+
97
+ def collect_fields
98
+ fields = []
99
+ @output.puts '4. Add fields. Leave the next field name blank when finished.'
100
+ loop do
101
+ name = validated('Field name') do |value|
102
+ if value.empty?
103
+ raise InvalidInput, 'Add at least one field.' if fields.empty?
104
+ else
105
+ Configuration.field!(value, 'string')
106
+ raise InvalidInput, 'That field already exists.' if fields.any? { |field| field[:name] == value }
107
+ end
108
+ value
109
+ end
110
+ break if name.empty?
111
+ types = Configuration::TYPES.keys
112
+ @output.puts types.each_with_index.map { |type, index| " #{index + 1}. #{type}" }.join("\n")
113
+ type = validated('Type (number or name)', default: 'string') do |value|
114
+ selected = value.match?(/\A[1-9][0-9]*\z/) ? types[value.to_i - 1] : value
115
+ raise InvalidInput, 'Choose a listed type.' unless types.include?(selected)
116
+ selected
117
+ end
118
+ fields << {name: name, type: type}
119
+ end
120
+ fields
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MK
4
+ module Generator
5
+ class InvalidInput < ArgumentError; end
6
+
7
+ class Configuration
8
+ ACTIONS = %w[index show create update delete].freeze
9
+ TYPES = {
10
+ 'string' => ['String', 'String', 'Example'],
11
+ 'text' => ['String', 'String', 'Example text'],
12
+ 'integer' => ['Integer', 'Integer', 1],
13
+ 'float' => ['Float', '[Integer, Float]', 1.5],
14
+ 'boolean' => ['TrueClass', ':boolean', false],
15
+ 'date' => ['Date', 'String', '2026-01-01'],
16
+ 'datetime' => ['DateTime', 'String', '2026-01-01T12:00:00Z']
17
+ }.transform_values(&:freeze).freeze
18
+ KEYWORDS = %w[alias and begin break case class def defined do else elsif end ensure false
19
+ for if in module next nil not or redo rescue retry return self super then
20
+ true undef unless until when while yield __FILE__ __LINE__ __ENCODING__].freeze
21
+ RESERVED_FIELDS = (KEYWORDS + %w[id created_at updated_at save save_changes destroy delete
22
+ values errors valid validate set update refresh new db dataset model table_name columns
23
+ pk pk_hash this associations changed_columns raise fail send public_send method methods
24
+ object_id instance_eval instance_exec initialize hash eql equal freeze frozen inspect
25
+ to_s to_json to_hash to_a tap then itself dup clone before_validation after_validation
26
+ before_save after_save before_create after_create before_update after_update
27
+ before_destroy after_destroy around_validation around_save around_create around_update
28
+ around_destroy set_fields update_fields validate_save skip_validation_on_next_save
29
+ get_column_value set_column_value raise_on_save_failure use_transactions require_modification]).freeze
30
+ RESERVED_MODELS = %w[app controller handler database tasks root db].freeze
31
+ RESERVED_CONSTANTS = %w[Object BasicObject Class Module Kernel String Integer Float Numeric
32
+ TrueClass FalseClass NilClass Array Hash Symbol Date DateTime Time File Dir Io Process
33
+ Thread Exception StandardError Sequel Roda Rack Rspec Rake Json Logger Erb FileUtils].freeze
34
+
35
+ attr_reader :app_name, :model_name, :resource, :fields
36
+
37
+ def self.identifier!(value, label: 'Name')
38
+ unless value.is_a?(String) && value.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/) && !KEYWORDS.include?(value)
39
+ raise InvalidInput, "#{label} must be a lowercase Ruby name, such as blog or blog_post."
40
+ end
41
+ value
42
+ end
43
+
44
+ def self.model_name!(value)
45
+ identifier!(value, label: 'Model name')
46
+ if RESERVED_MODELS.include?(value) || RESERVED_CONSTANTS.include?(camelize(value))
47
+ raise InvalidInput, 'That model name is reserved by Ruby or the application.'
48
+ end
49
+ value
50
+ end
51
+
52
+ def self.app_name!(value)
53
+ identifier!(value, label: 'App name')
54
+ raise InvalidInput, 'That app name conflicts with a Ruby or library constant.' if RESERVED_CONSTANTS.include?(camelize(value))
55
+ value
56
+ end
57
+
58
+ def self.field!(name, type)
59
+ identifier!(name, label: 'Field name')
60
+ raise InvalidInput, "Field #{name} is reserved; id and timestamps are generated automatically." if RESERVED_FIELDS.include?(name)
61
+ raise InvalidInput, "Unknown field type: #{type}." unless TYPES.key?(type)
62
+ {name: name.dup.freeze, type: type.dup.freeze}.freeze
63
+ end
64
+
65
+ def self.camelize(name) = name.split('_').map(&:capitalize).join
66
+
67
+ def initialize(app_name:, model_name:, resource:, fields:)
68
+ @app_name = self.class.app_name!(app_name).dup.freeze
69
+ @model_name = self.class.model_name!(model_name).dup.freeze
70
+ @resource = self.class.identifier!(resource, label: 'Resource name').dup.freeze
71
+ if ACTIONS.product(%w[Controller Handler]).any? { |action, kind| action_prefix + action.capitalize + kind == model_class }
72
+ raise InvalidInput, 'Model name conflicts with a generated action class.'
73
+ end
74
+ raise InvalidInput, 'Add at least one field.' if fields.empty?
75
+ @fields = fields.map { |field| self.class.field!(field.fetch(:name), field.fetch(:type)) }.freeze
76
+ raise InvalidInput, 'Field names must be unique.' unless @fields.map { |f| f[:name] }.uniq.length == @fields.length
77
+ freeze
78
+ end
79
+
80
+ def namespace = self.class.camelize(app_name)
81
+ def model_class = self.class.camelize(model_name)
82
+ def action_prefix = self.class.camelize(resource)
83
+ def field_names = fields.map { |field| field[:name] }
84
+ def example = fields.to_h { |field| [field[:name], TYPES.fetch(field[:type])[2]] }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module MK
6
+ module Generator
7
+ module Naming
8
+ IRREGULAR = {'person' => 'people', 'child' => 'children', 'man' => 'men', 'woman' => 'women'}.freeze
9
+
10
+ def self.singular(name)
11
+ return IRREGULAR.key(name) if IRREGULAR.value?(name)
12
+ return "#{name[0...-3]}y" if name.end_with?('ies')
13
+ return name[0...-2] if name.match?(/(?:sses|shes|ches|xes|zes|statuses)\z/)
14
+ name.end_with?('s') && !name.end_with?('ss', 'us') ? name[0...-1] : name
15
+ end
16
+
17
+ def self.plural(name)
18
+ return IRREGULAR.fetch(name) if IRREGULAR.key?(name)
19
+ return "#{name[0...-1]}ies" if name.match?(/[^aeiou]y\z/)
20
+ name.match?(/(?:s|sh|ch|x|z)\z/) ? "#{name}es" : "#{name}s"
21
+ end
22
+ end
23
+
24
+ # This is a small data grammar, never Ruby evaluation or shell input.
25
+ class Options
26
+ def self.parse(source)
27
+ new(source).parse
28
+ end
29
+
30
+ def initialize(source)
31
+ @scanner = StringScanner.new(source)
32
+ end
33
+
34
+ def parse
35
+ options = {}
36
+ loop do
37
+ key = identifier
38
+ raise InvalidInput, "Unknown option: #{key}." unless %w[app_name model_name resource_name fields].include?(key)
39
+ raise InvalidInput, "Duplicate option: #{key}." if options.key?(key)
40
+ token(':')
41
+ options[key] = key == 'fields' ? fields : identifier
42
+ whitespace
43
+ break if @scanner.eos?
44
+ token(',')
45
+ end
46
+ %w[app_name model_name fields].each do |key|
47
+ raise InvalidInput, "Missing option: #{key}." unless options.key?(key)
48
+ end
49
+ model = Naming.singular(options.fetch('model_name'))
50
+ Configuration.new(app_name: options.fetch('app_name'), model_name: model,
51
+ resource: options.fetch('resource_name') { Naming.plural(model) }, fields: options.fetch('fields'))
52
+ end
53
+
54
+ private
55
+
56
+ def fields
57
+ token('[')
58
+ result = []
59
+ whitespace
60
+ unless @scanner.peek(1) == ']'
61
+ loop do
62
+ name = identifier
63
+ token(':')
64
+ result << {name: name, type: identifier}
65
+ whitespace
66
+ break if @scanner.peek(1) == ']'
67
+ token(',')
68
+ end
69
+ end
70
+ token(']')
71
+ result
72
+ end
73
+
74
+ def whitespace = @scanner.skip(/\s*/)
75
+
76
+ def identifier
77
+ whitespace
78
+ @scanner.scan(/[a-z][a-z0-9_]*/) || invalid!
79
+ end
80
+
81
+ def token(value)
82
+ whitespace
83
+ invalid! unless @scanner.scan(/#{Regexp.escape(value)}/)
84
+ end
85
+
86
+ def invalid!
87
+ raise InvalidInput, 'Invalid --cli syntax. Use app_name:blog, model_name:posts, fields:[title:string, contents:text].'
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require_relative '../version'
7
+
8
+ module MK
9
+ module Generator
10
+ class Project
11
+ TEMPLATES = {
12
+ 'Gemfile' => 'Gemfile', 'Rakefile' => 'Rakefile', 'gitignore' => '.gitignore',
13
+ 'README.md' => 'README.md', 'app.rb' => 'app.rb', 'config.ru' => 'config.ru',
14
+ 'database.rb' => 'database.rb', 'migration.rb' => 'db/migrations/001_initial.rb',
15
+ 'model.rb' => 'models/%{model}.rb',
16
+ 'spec_helper.rb' => 'spec/spec_helper.rb', 'request_spec.rb' => 'spec/request/%{resource}_spec.rb'
17
+ }.freeze
18
+
19
+ def initialize(configuration)
20
+ @configuration = configuration
21
+ end
22
+
23
+ def generate(destination)
24
+ destination = File.expand_path(destination)
25
+ raise InvalidInput, 'Destination already exists; choose a new directory.' if File.exist?(destination) || File.symlink?(destination)
26
+ parent = File.dirname(destination)
27
+ raise InvalidInput, 'Destination parent directory does not exist.' unless File.directory?(parent)
28
+
29
+ # Render before touching the destination, then reserve it without overwriting anything.
30
+ files = render
31
+ Dir.mkdir(destination)
32
+ files.each do |relative, content|
33
+ target = File.join(destination, relative)
34
+ FileUtils.mkdir_p(File.dirname(target))
35
+ File.write(target, content, mode: 'wx')
36
+ end
37
+ destination
38
+ rescue Errno::EEXIST
39
+ raise InvalidInput, 'Destination already exists; choose a new directory.'
40
+ end
41
+
42
+ def render
43
+ config = @configuration
44
+ files = TEMPLATES.to_h do |template, path|
45
+ source = File.read(File.join(__dir__, 'templates', "#{template}.erb"))
46
+ [format(path, model: config.model_name, resource: config.resource), ERB.new(source, trim_mode: '-').result(binding)]
47
+ end
48
+ Configuration::ACTIONS.each do |action|
49
+ %w[controller handler].each do |kind|
50
+ source = File.read(File.join(__dir__, 'templates', "#{kind}.rb.erb"))
51
+ files["routes/#{config.resource}/#{kind}s/#{action}.rb"] = ERB.new(source, trim_mode: '-').result(binding)
52
+ end
53
+ end
54
+ files
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require_relative '../generator'
5
+
6
+ namespace :mk_framework do
7
+ desc 'Generate an app (optional DESTINATION=/path/to/app and APP_SPEC for non-interactive input)'
8
+ task :init do
9
+ arguments = ENV['DESTINATION'] ? [ENV.fetch('DESTINATION')] : []
10
+ arguments += ['--cli', ENV.fetch('APP_SPEC')] if ENV['APP_SPEC']
11
+ abort 'App generation did not complete.' unless MK::Generator::CLI.run(arguments).zero?
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'mk_framework', '~> <%= MK::VERSION %>'
4
+ gem 'sequel', '>= 5.92', '< 6'
5
+ gem 'sqlite3', '~> 2.9'
6
+ gem 'rake', '~> 13.4'
7
+ gem 'rackup', '~> 2.3'
8
+ gem 'puma', '~> 8.0'
9
+
10
+ group :test do
11
+ gem 'rspec', '~> 3.13'
12
+ gem 'rack-test', '~> 2.2'
13
+ end
@@ -0,0 +1,70 @@
1
+ # <%= config.namespace %>
2
+
3
+ A self-contained MK app with one `<%= config.model_class %>` model and a full CRUD resource.
4
+ Each action has its own controller and handler.
5
+
6
+ | Method | Path | Action |
7
+ | --- | --- | --- |
8
+ | GET | `/<%= config.resource %>` | index |
9
+ | GET | `/<%= config.resource %>/:id` | show |
10
+ | POST | `/<%= config.resource %>` | create |
11
+ | PATCH / PUT | `/<%= config.resource %>/:id` | update |
12
+ | DELETE | `/<%= config.resource %>/:id` | delete |
13
+
14
+ Create returns 201; the other actions return 200. Index returns
15
+ `{<%= config.resource %>: [...]}`; member actions return `{<%= config.model_name %>: {...}}`,
16
+ including the deleted record for DELETE. Missing records return 404.
17
+
18
+ ## Setup
19
+
20
+ ```sh
21
+ bundle install
22
+ bundle exec rake db:migrate
23
+ bundle exec rake routes
24
+ bundle exec rspec
25
+ ```
26
+
27
+ Migrations run explicitly, before boot. Development data defaults to
28
+ `<%= config.app_name %>.db` beside `database.rb`. `DATABASE_URL`, `DB_POOL_SIZE`, and
29
+ `DB_POOL_TIMEOUT` configure other connections. Tests always use private in-memory
30
+ SQLite databases and ignore `DATABASE_URL`. Datetimes are stored and read in UTC.
31
+
32
+ To serve the app locally:
33
+
34
+ ```sh
35
+ bundle exec rake # defaults to rake dev; Puma on http://127.0.0.1:3000
36
+ # or: bundle exec rake dev
37
+ ```
38
+
39
+ Set `HOST` or `PORT` to override the bind address or port.
40
+
41
+ Then create a record:
42
+
43
+ ```sh
44
+ curl -i http://127.0.0.1:3000/<%= config.resource %> \
45
+ -H 'Content-Type: application/json' \
46
+ -d '<%= JSON.generate(config.example) %>'
47
+ ```
48
+
49
+ All chosen fields are required on create. PATCH and PUT update only supplied fields
50
+ and preserve omitted fields. Invalid input types return 400; missing fields or
51
+ blank text return 422. Date and datetime inputs use ISO 8601 strings. Booleans
52
+ accept JSON `true` and `false`; numeric fields accept JSON numbers. Unknown fields
53
+ are ignored. IDs and timestamps are assigned by the app.
54
+
55
+ ## Files and next changes
56
+
57
+ - `database.rb` connects; `db/migrations/001_initial.rb` defines the table.
58
+ - `models/<%= config.model_name %>.rb` owns validations and the public field list.
59
+ - `app.rb` loads dependencies and models, configures routes, and calls `boot!`.
60
+ - `config.ru` exposes `<%= config.namespace %>::App.app` to Rack.
61
+ - `routes/<%= config.resource %>/controllers/` contains index, show, create, update, and delete actions.
62
+ - `routes/<%= config.resource %>/handlers/` filters raw data and formats each response.
63
+ - MK saves create/update models once and destroys delete models once.
64
+ - `spec/request/<%= config.resource %>_spec.rb` checks persistence and error responses.
65
+
66
+ Add a field through a new migration, update permitted input and model validation,
67
+ then decide whether to expose it in `public_attributes_list`. Add another action by
68
+ creating its controller/handler pair and declaring the route in `app.rb`.
69
+ The starter has no authentication: implement access rules in controllers before
70
+ using it for private data. Handlers must not query or persist records.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'rbconfig'
5
+
6
+ namespace :db do
7
+ desc 'Apply database migrations explicitly'
8
+ task :migrate do
9
+ require_relative 'database'
10
+ Sequel::Migrator.run(<%= config.namespace %>::DB, File.join(__dir__, 'db/migrations'))
11
+ end
12
+ end
13
+
14
+ desc 'Print the compiled resource routes'
15
+ task :routes do
16
+ require_relative 'app'
17
+ puts <%= config.namespace %>::App.route_table
18
+ end
19
+
20
+ desc 'Run isolated request specs'
21
+ task :spec do
22
+ sh RbConfig.ruby, '-S', 'rspec', File.join(__dir__, 'spec')
23
+ end
24
+
25
+ desc 'Start Puma on port 3000 (HOST and PORT override the defaults)'
26
+ task :dev do
27
+ exec RbConfig.ruby, '-S', 'puma', '--bind',
28
+ "tcp://#{ENV.fetch('HOST', '127.0.0.1')}:#{ENV.fetch('PORT', '3000')}",
29
+ File.join(__dir__, 'config.ru'), chdir: __dir__
30
+ end
31
+
32
+ task default: :dev
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mk_framework/sequel'
4
+ require_relative 'database'
5
+ require_relative 'models/<%= config.model_name %>'
6
+
7
+ module <%= config.namespace %>
8
+ class App < MK::Application
9
+ configure root: ROOT, namespace: ::<%= config.namespace %>, legacy_post_routes: false
10
+
11
+ resource_routes do
12
+ resources :<%= config.resource %>, singular: '<%= config.model_name %>'
13
+ end
14
+ end
15
+
16
+ App.boot!
17
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'app'
4
+ run <%= config.namespace %>::App.app
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ <% if %w[create update].include?(action) && config.fields.any? { |field| %w[date datetime].include?(field[:type]) } -%>
3
+
4
+ require 'date'
5
+ <% end -%>
6
+
7
+ module <%= config.namespace %>
8
+ class <%= config.action_prefix %><%= action.capitalize %>Controller < MK::Controller
9
+ route do |r|
10
+ <% if action == 'index' -%>
11
+ <%= config.model_class %>.order(:id)
12
+ <% elsif %w[show delete].include?(action) -%>
13
+ <%= config.model_class %>[r.path_params.fetch(:id)] or raise MK::NotFound
14
+ <% else -%>
15
+ <% if action == 'update' -%>
16
+ record = <%= config.model_class %>[r.path_params.fetch(:id)] or raise MK::NotFound
17
+ <% end -%>
18
+ attributes = r.input.permit(
19
+ <% config.fields.each_with_index do |field, index| -%>
20
+ <%= field[:name] %>: <%= MK::Generator::Configuration::TYPES.fetch(field[:type])[1] %><%= index < config.fields.length - 1 ? ',' : '' %>
21
+ <% end -%>
22
+ )
23
+ <% config.fields.select { |field| %w[date datetime].include?(field[:type]) }.each do |field| -%>
24
+ if attributes.key?(:<%= field[:name] %>)
25
+ begin
26
+ attributes[:<%= field[:name] %>] = <%= field[:type] == 'date' ? 'Date' : 'DateTime' %>.iso8601(attributes.fetch(:<%= field[:name] %>))
27
+ rescue ArgumentError
28
+ raise MK::BadRequest, 'Invalid parameter: <%= field[:name] %> (expected ISO 8601)'
29
+ end
30
+ end
31
+ <% end -%>
32
+ <% if action == 'create' -%>
33
+ <%= config.model_class %>.new(attributes)
34
+ <% else -%>
35
+ record.set(attributes)
36
+ <% end -%>
37
+ <% end -%>
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+ require 'sequel/extensions/migration'
5
+
6
+ Sequel.default_timezone = :utc
7
+
8
+ module <%= config.namespace %>
9
+ ROOT = __dir__.freeze
10
+ DB = if ENV['RACK_ENV'] == 'test'
11
+ Sequel.sqlite(max_connections: 1)
12
+ else
13
+ url = ENV.fetch('DATABASE_URL') { "sqlite://#{File.join(ROOT, '<%= config.app_name %>.db')}" }
14
+ Sequel.connect(url, max_connections: Integer(ENV.fetch('DB_POOL_SIZE', '5')),
15
+ pool_timeout: Integer(ENV.fetch('DB_POOL_TIMEOUT', '5')))
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ .bundle/
2
+ vendor/
3
+ .env
4
+ .env.*
5
+ *.db
6
+ *.db-*
7
+ *.sqlite*
8
+ log/
9
+ tmp/
10
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= config.namespace %>
4
+ class <%= config.action_prefix %><%= action.capitalize %>Handler < MK::Handler
5
+ handler do |r|
6
+ <% if action == 'create' -%>
7
+ r.response.status = 201
8
+ <% end -%>
9
+ <% if action == 'index' -%>
10
+ {<%= config.resource %>: model.map { |record| record.slice(*<%= config.model_class %>.public_attributes_list) }}
11
+ <% else -%>
12
+ {<%= config.model_name %>: model.slice(*<%= config.model_class %>.public_attributes_list)}
13
+ <% end -%>
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table :<%= config.resource %> do
6
+ primary_key :id
7
+ <% config.fields.each do |field| -%>
8
+ <%= MK::Generator::Configuration::TYPES.fetch(field[:type])[0] %> :<%= field[:name] %>, null: false<%= field[:type] == 'text' ? ', text: true' : '' %>
9
+ <% end -%>
10
+ DateTime :created_at, null: false
11
+ DateTime :updated_at, null: false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= config.namespace %>
4
+ class <%= config.model_class %> < Sequel::Model(DB[:<%= config.resource %>])
5
+ plugin :validation_helpers
6
+ plugin :timestamps, update_on_create: true
7
+
8
+ def validate
9
+ super
10
+ validates_not_null %i[<%= config.field_names.join(' ') %>]
11
+ <% text_fields = config.fields.select { |field| %w[string text].include?(field[:type]) }.map { |field| field[:name] } -%>
12
+ <% unless text_fields.empty? -%>
13
+ validates_presence %i[<%= text_fields.join(' ') %>]
14
+ <% end -%>
15
+ end
16
+
17
+ def self.public_attributes_list
18
+ %i[id <%= config.field_names.join(' ') %> created_at updated_at]
19
+ end
20
+ end
21
+ end