radical 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +4 -0
- data/.solargraph.yml +10 -0
- data/CHANGELOG.md +27 -0
- data/Dockerfile +34 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +49 -0
- data/Rakefile +11 -0
- data/lib/radical/app.rb +103 -0
- data/lib/radical/controller.rb +144 -0
- data/lib/radical/database.rb +92 -0
- data/lib/radical/env.rb +23 -0
- data/lib/radical/form.rb +47 -0
- data/lib/radical/model.rb +128 -0
- data/lib/radical/router.rb +144 -0
- data/lib/radical/table.rb +23 -0
- data/lib/radical/view.rb +58 -0
- data/lib/radical.rb +4 -0
- data/radical.gemspec +46 -0
- data/sorbet/config +3 -0
- data/sorbet/rbi/sorbet-typed/lib/minitest/all/minitest.rbi +108 -0
- data/sorbet/rbi/sorbet-typed/lib/rake/all/rake.rbi +645 -0
- metadata +235 -0
@@ -0,0 +1,128 @@
|
|
1
|
+
require_relative 'database'
|
2
|
+
|
3
|
+
module Radical
|
4
|
+
class ModelNotFound < StandardError; end
|
5
|
+
|
6
|
+
class Model
|
7
|
+
class << self
|
8
|
+
attr_accessor :table_name
|
9
|
+
|
10
|
+
def database(name)
|
11
|
+
conn = SQLite3::Database.new name
|
12
|
+
conn.results_as_hash = true
|
13
|
+
conn.type_translation = true
|
14
|
+
Database.connection = conn
|
15
|
+
end
|
16
|
+
|
17
|
+
def prepend_migrations_path(path)
|
18
|
+
Database.migrations_path = path
|
19
|
+
end
|
20
|
+
|
21
|
+
def db
|
22
|
+
Database.connection
|
23
|
+
end
|
24
|
+
|
25
|
+
def table(name)
|
26
|
+
self.table_name = name
|
27
|
+
end
|
28
|
+
|
29
|
+
def columns
|
30
|
+
sql = "select name from pragma_table_info('#{table_name}');"
|
31
|
+
|
32
|
+
@columns ||= db.execute(sql).map { |r| r['name'] }
|
33
|
+
end
|
34
|
+
|
35
|
+
def save_columns
|
36
|
+
columns.reject { |c| %w[id created_at updated_at].include?(c) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def find(id)
|
40
|
+
sql = "select * from #{table_name} where id = ? limit 1"
|
41
|
+
|
42
|
+
row = db.get_first_row sql, [id.to_i]
|
43
|
+
|
44
|
+
raise ModelNotFound, 'Record not found' unless row
|
45
|
+
|
46
|
+
new(row)
|
47
|
+
end
|
48
|
+
|
49
|
+
def all
|
50
|
+
sql = "select * from #{table_name} order by id"
|
51
|
+
|
52
|
+
rows = db.execute sql
|
53
|
+
|
54
|
+
rows.map { |r| new(r) }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def initialize(params = {})
|
59
|
+
columns.each do |column|
|
60
|
+
self.class.attr_accessor column.to_sym
|
61
|
+
instance_variable_set "@#{column}", params[column]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def columns
|
66
|
+
self.class.columns
|
67
|
+
end
|
68
|
+
|
69
|
+
def save_columns
|
70
|
+
self.class.save_columns
|
71
|
+
end
|
72
|
+
|
73
|
+
def db
|
74
|
+
self.class.db
|
75
|
+
end
|
76
|
+
|
77
|
+
def table_name
|
78
|
+
self.class.table_name
|
79
|
+
end
|
80
|
+
|
81
|
+
def delete
|
82
|
+
sql = "delete from #{table_name} where id = ? limit 1"
|
83
|
+
|
84
|
+
db.execute sql, id.to_i
|
85
|
+
|
86
|
+
self
|
87
|
+
end
|
88
|
+
|
89
|
+
def update(params)
|
90
|
+
save_columns.each { |c| instance_variable_set("@#{c}", params[c]) }
|
91
|
+
|
92
|
+
save
|
93
|
+
end
|
94
|
+
|
95
|
+
def save
|
96
|
+
values = save_columns.map { |c| instance_variable_get("@#{c}") }
|
97
|
+
|
98
|
+
if saved?
|
99
|
+
sql = <<-SQL
|
100
|
+
update #{table_name} set #{save_columns.map { |c| "#{c}=?" }.join(',')}, updated_at = ? where id = ?
|
101
|
+
SQL
|
102
|
+
|
103
|
+
db.transaction do |t|
|
104
|
+
t.execute sql, values + [Time.now.to_i, id]
|
105
|
+
self.class.find(id)
|
106
|
+
end
|
107
|
+
else
|
108
|
+
sql = <<-SQL
|
109
|
+
insert into #{table_name} (
|
110
|
+
#{save_columns.join(',')}
|
111
|
+
)
|
112
|
+
values (
|
113
|
+
#{save_columns.map { '?' }.join(',')}
|
114
|
+
)
|
115
|
+
SQL
|
116
|
+
|
117
|
+
db.transaction do |t|
|
118
|
+
t.execute sql, values
|
119
|
+
self.class.find t.last_insert_row_id
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def saved?
|
125
|
+
!id.nil?
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# typed: true
|
2
|
+
require 'rack'
|
3
|
+
require 'sorbet-runtime'
|
4
|
+
|
5
|
+
# A very naive router for radical
|
6
|
+
#
|
7
|
+
# This class loops over routes for each http method (GET, POST, etc.)
|
8
|
+
# and checks a simple regex built at startup
|
9
|
+
#
|
10
|
+
# '/users/:id' => "/users/:^#{path.gsub(/:(\w+)/, '(?<\1>[a-zA-Z0-9]+)')}$"
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
#
|
14
|
+
# router = Router.new do
|
15
|
+
# get '/users/:id', to: 'users#show'
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# router.route(
|
19
|
+
# {
|
20
|
+
# 'PATH_INFO' => '/users/1',
|
21
|
+
# 'REQUEST_METHOD' => 'GET'
|
22
|
+
# }
|
23
|
+
# ) => 'users#show(1)'
|
24
|
+
#
|
25
|
+
# Dispatches to:
|
26
|
+
#
|
27
|
+
# class UsersController < Controller
|
28
|
+
# def show
|
29
|
+
# render plain: "users#show(#{params['id']})"
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
module Radical
|
33
|
+
class Router
|
34
|
+
extend T::Sig
|
35
|
+
|
36
|
+
ACTIONS = [
|
37
|
+
[:index, 'GET', ''],
|
38
|
+
[:new, 'GET', '/new'],
|
39
|
+
[:show, 'GET', '/:id'],
|
40
|
+
[:create, 'POST', ''],
|
41
|
+
[:edit, 'GET', '/:id/edit'],
|
42
|
+
[:update, 'PUT', '/:id'],
|
43
|
+
[:update, 'PATCH', '/:id'],
|
44
|
+
[:destroy, 'DELETE', '/:id']
|
45
|
+
].freeze
|
46
|
+
|
47
|
+
RESOURCE_ACTIONS = [
|
48
|
+
[:show, 'GET', ''],
|
49
|
+
[:edit, 'GET', '/edit'],
|
50
|
+
[:update, 'PUT', ''],
|
51
|
+
[:update, 'PATCH', ''],
|
52
|
+
[:destroy, 'DELETE', '']
|
53
|
+
].freeze
|
54
|
+
|
55
|
+
attr_accessor :routes
|
56
|
+
|
57
|
+
sig { void }
|
58
|
+
def initialize
|
59
|
+
@routes = Hash.new { |hash, key| hash[key] = [] }
|
60
|
+
end
|
61
|
+
|
62
|
+
sig { params(classes: T::Array[Class]).returns(String) }
|
63
|
+
def route_prefix(classes)
|
64
|
+
classes.map(&:route_name).map { |n| "#{n}/:#{n}_id" }.join('/')
|
65
|
+
end
|
66
|
+
|
67
|
+
sig { params(klass: Class).void }
|
68
|
+
def add_root(klass)
|
69
|
+
add_actions(klass, name: '')
|
70
|
+
end
|
71
|
+
|
72
|
+
sig { params(klass: Class, name: T.nilable(String), prefix: T.nilable(String), actions: Array).void }
|
73
|
+
def add_actions(klass, name: nil, prefix: nil, actions: ACTIONS)
|
74
|
+
name ||= klass.route_name
|
75
|
+
|
76
|
+
actions.each do |method, http_method, suffix|
|
77
|
+
next unless klass.method_defined?(method)
|
78
|
+
|
79
|
+
path = "/#{prefix}#{name}#{suffix}"
|
80
|
+
path = Regexp.new("^#{path.gsub(/:(\w+)/, '(?<\1>[a-zA-Z0-9_]+)')}$")
|
81
|
+
|
82
|
+
if %i[index create show update destroy].include?(method) && !klass.method_defined?(:"#{klass.route_name}_path")
|
83
|
+
klass.define_method :"#{klass.route_name}_path" do |obj = nil|
|
84
|
+
if obj.is_a?(Model)
|
85
|
+
"/#{klass.route_name}/#{obj.id}"
|
86
|
+
else
|
87
|
+
"/#{klass.route_name}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
if method == :new
|
93
|
+
klass.define_method :"new_#{klass.route_name}_path" do
|
94
|
+
"/#{klass.route_name}/new"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
if method == :edit
|
99
|
+
klass.define_method :"edit_#{klass.route_name}_path" do |obj|
|
100
|
+
"/#{klass.route_name}/#{obj.id}/edit"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
@routes[http_method] << [path, [klass, method]]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
sig { params(classes: T::Array[Class], prefix: T.nilable(String), actions: Array).void }
|
109
|
+
def add_routes(classes, prefix: nil, actions: ACTIONS)
|
110
|
+
classes.each do |klass|
|
111
|
+
add_actions(klass, prefix: prefix, actions: actions)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
sig { params(request: Rack::Request).returns(Rack::Response) }
|
116
|
+
def route(request)
|
117
|
+
params = T.let({}, T.nilable(Hash))
|
118
|
+
|
119
|
+
route = @routes[request.request_method].find do |r|
|
120
|
+
params = request.path_info.match(r.first)&.named_captures
|
121
|
+
end
|
122
|
+
|
123
|
+
return Rack::Response.new('404 Not Found', 404) unless route
|
124
|
+
|
125
|
+
klass, method = route.last
|
126
|
+
|
127
|
+
params.each do |k, v|
|
128
|
+
request.update_param(k, v)
|
129
|
+
end
|
130
|
+
|
131
|
+
instance = klass.new(request)
|
132
|
+
|
133
|
+
response = instance.public_send(method)
|
134
|
+
|
135
|
+
return response if response.is_a?(Rack::Response)
|
136
|
+
|
137
|
+
body = instance.view(method.to_s)
|
138
|
+
|
139
|
+
return Rack::Response.new(nil, 404) if body.nil?
|
140
|
+
|
141
|
+
Rack::Response.new(body, 200, { 'Content-Type' => 'text/html' })
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Radical
|
2
|
+
class Table
|
3
|
+
attr_accessor :columns
|
4
|
+
|
5
|
+
def initialize(table)
|
6
|
+
@table = table
|
7
|
+
@columns = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def string(name)
|
11
|
+
@columns << "#{name} text"
|
12
|
+
end
|
13
|
+
|
14
|
+
def integer(name)
|
15
|
+
@columns << "#{name} integer"
|
16
|
+
end
|
17
|
+
|
18
|
+
def timestamps
|
19
|
+
@columns << "created_at integer not null default(strftime('%s', 'now'))"
|
20
|
+
@columns << 'updated_at integer'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/radical/view.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'erubi'
|
2
|
+
require 'tilt'
|
3
|
+
|
4
|
+
module Radical
|
5
|
+
class CaptureEngine < ::Erubi::Engine
|
6
|
+
private
|
7
|
+
|
8
|
+
BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/.freeze
|
9
|
+
|
10
|
+
def add_expression(indicator, code)
|
11
|
+
if BLOCK_EXPR.match?(code) && %w[== =].include?(indicator)
|
12
|
+
src << ' ' << code
|
13
|
+
else
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class View
|
20
|
+
class << self
|
21
|
+
attr_accessor :_views_path, :_layout
|
22
|
+
|
23
|
+
def view_path(dir, name)
|
24
|
+
filename = File.join(@_views_path, 'views', dir, "#{name}.erb")
|
25
|
+
|
26
|
+
raise "Could not find view file: #{filename}. You need to create it." unless File.exist?(filename)
|
27
|
+
|
28
|
+
filename
|
29
|
+
end
|
30
|
+
|
31
|
+
def template(dir, name)
|
32
|
+
Tilt.new(view_path(dir, name), engine_class: CaptureEngine, escape_html: true)
|
33
|
+
end
|
34
|
+
|
35
|
+
def path(path = nil, test = Env.test?)
|
36
|
+
@_views_path = path || ((test ? 'test' : '') + __dir__)
|
37
|
+
end
|
38
|
+
|
39
|
+
def layout(name)
|
40
|
+
@_layout = name
|
41
|
+
end
|
42
|
+
|
43
|
+
def render(dir, name, scope, options = {})
|
44
|
+
t = template(dir, name)
|
45
|
+
|
46
|
+
if @_layout && options[:layout] != false
|
47
|
+
layout = template('', @_layout)
|
48
|
+
|
49
|
+
layout.render(scope, {}) do
|
50
|
+
t.render scope
|
51
|
+
end
|
52
|
+
else
|
53
|
+
t.render scope
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
data/lib/radical.rb
ADDED
data/radical.gemspec
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.platform = Gem::Platform::RUBY
|
5
|
+
spec.name = 'radical'
|
6
|
+
spec.version = '1.0.0'
|
7
|
+
spec.author = 'Sean Walker'
|
8
|
+
spec.email = 'sean@swlkr.com'
|
9
|
+
|
10
|
+
spec.summary = 'Rails inspired web framework'
|
11
|
+
spec.description = 'This gem helps you write rails-like web applications'
|
12
|
+
spec.homepage = 'https://github.com/swlkr/radical'
|
13
|
+
spec.license = 'MIT'
|
14
|
+
|
15
|
+
if spec.respond_to?(:metadata)
|
16
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
17
|
+
spec.metadata['source_code_uri'] = 'https://github.com/swlkr/radical'
|
18
|
+
spec.metadata['changelog_uri'] = 'https://github.com/swlkr/radical/CHANGELOG.md'
|
19
|
+
else
|
20
|
+
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
21
|
+
'public gem pushes.'
|
22
|
+
end
|
23
|
+
|
24
|
+
# Specify which files should be added to the gem when it is released.
|
25
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
26
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
27
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(docs|examples|test|Dockerfile|\.rubocop.yml|\.solargraph.yml|\.github)/}) }
|
28
|
+
end
|
29
|
+
spec.bindir = 'exe'
|
30
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
31
|
+
spec.require_paths = ['lib']
|
32
|
+
|
33
|
+
spec.add_development_dependency 'minitest', '~> 5.14'
|
34
|
+
spec.add_development_dependency 'puma', '~> 5.5'
|
35
|
+
spec.add_development_dependency 'rack-test', '~> 1.1'
|
36
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
37
|
+
spec.add_development_dependency 'sorbet', '~> 0.5'
|
38
|
+
|
39
|
+
spec.add_dependency 'erubi', '~> 1.10'
|
40
|
+
spec.add_dependency 'rack', '~> 2.2'
|
41
|
+
spec.add_dependency 'rack_csrf', '~> 2.6'
|
42
|
+
spec.add_dependency 'rack-flash3', '~> 1.0'
|
43
|
+
spec.add_dependency 'sorbet-runtime', '~> 0.5'
|
44
|
+
spec.add_dependency 'sqlite3', '~> 1.4'
|
45
|
+
spec.add_dependency 'tilt', '~> 2.0'
|
46
|
+
end
|
data/sorbet/config
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
# This file is autogenerated. Do not edit it by hand. Regenerate it with:
|
2
|
+
# srb rbi sorbet-typed
|
3
|
+
#
|
4
|
+
# If you would like to make changes to this file, great! Please upstream any changes you make here:
|
5
|
+
#
|
6
|
+
# https://github.com/sorbet/sorbet-typed/edit/master/lib/minitest/all/minitest.rbi
|
7
|
+
#
|
8
|
+
# typed: strong
|
9
|
+
|
10
|
+
module Minitest
|
11
|
+
class Runnable
|
12
|
+
end
|
13
|
+
|
14
|
+
class Test < Runnable
|
15
|
+
include Minitest::Assertions
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { void }
|
19
|
+
def self.autorun; end
|
20
|
+
|
21
|
+
sig { params(args: T::Array[String]).returns(T::Boolean) }
|
22
|
+
def self.run(args = []); end
|
23
|
+
end
|
24
|
+
|
25
|
+
module Minitest::Assertions
|
26
|
+
extend T::Sig
|
27
|
+
|
28
|
+
sig { params(test: T.untyped, msg: T.nilable(String)).returns(TrueClass) }
|
29
|
+
def assert(test, msg = nil); end
|
30
|
+
|
31
|
+
sig do
|
32
|
+
params(
|
33
|
+
exp: BasicObject,
|
34
|
+
msg: T.nilable(String)
|
35
|
+
).returns(TrueClass)
|
36
|
+
end
|
37
|
+
def assert_empty(exp, msg = nil); end
|
38
|
+
|
39
|
+
sig do
|
40
|
+
params(
|
41
|
+
exp: BasicObject,
|
42
|
+
act: BasicObject,
|
43
|
+
msg: T.nilable(String)
|
44
|
+
).returns(TrueClass)
|
45
|
+
end
|
46
|
+
def assert_equal(exp, act, msg = nil); end
|
47
|
+
|
48
|
+
sig do
|
49
|
+
params(
|
50
|
+
collection: T::Enumerable[T.untyped],
|
51
|
+
obj: BasicObject,
|
52
|
+
msg: T.nilable(String)
|
53
|
+
).returns(TrueClass)
|
54
|
+
end
|
55
|
+
def assert_includes(collection, obj, msg = nil); end
|
56
|
+
|
57
|
+
sig do
|
58
|
+
params(
|
59
|
+
obj: BasicObject,
|
60
|
+
msg: T.nilable(String)
|
61
|
+
).returns(TrueClass)
|
62
|
+
end
|
63
|
+
def assert_nil(obj, msg = nil); end
|
64
|
+
|
65
|
+
sig do
|
66
|
+
params(
|
67
|
+
exp: T.untyped
|
68
|
+
).returns(StandardError)
|
69
|
+
end
|
70
|
+
def assert_raises(*exp, &block); end
|
71
|
+
|
72
|
+
sig { params(test: T.untyped, msg: T.nilable(String)).returns(TrueClass) }
|
73
|
+
def refute(test, msg = nil); end
|
74
|
+
|
75
|
+
sig do
|
76
|
+
params(
|
77
|
+
exp: BasicObject,
|
78
|
+
msg: T.nilable(String)
|
79
|
+
).returns(TrueClass)
|
80
|
+
end
|
81
|
+
def refute_empty(exp, msg = nil); end
|
82
|
+
|
83
|
+
sig do
|
84
|
+
params(
|
85
|
+
exp: BasicObject,
|
86
|
+
act: BasicObject,
|
87
|
+
msg: T.nilable(String)
|
88
|
+
).returns(TrueClass)
|
89
|
+
end
|
90
|
+
def refute_equal(exp, act, msg = nil); end
|
91
|
+
|
92
|
+
sig do
|
93
|
+
params(
|
94
|
+
collection: T::Enumerable[T.untyped],
|
95
|
+
obj: BasicObject,
|
96
|
+
msg: T.nilable(String)
|
97
|
+
).returns(TrueClass)
|
98
|
+
end
|
99
|
+
def refute_includes(collection, obj, msg = nil); end
|
100
|
+
|
101
|
+
sig do
|
102
|
+
params(
|
103
|
+
obj: BasicObject,
|
104
|
+
msg: T.nilable(String)
|
105
|
+
).returns(TrueClass)
|
106
|
+
end
|
107
|
+
def refute_nil(obj, msg = nil); end
|
108
|
+
end
|