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.
@@ -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