radical 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|