radical 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -0,0 +1,4 @@
1
+ require_relative 'radical/app'
2
+ require_relative 'radical/controller'
3
+ require_relative 'radical/view'
4
+ require_relative 'radical/model'
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,3 @@
1
+ --dir
2
+ .
3
+ --ignore=/vendor/bundle
@@ -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