yescode 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/yes_record.rb ADDED
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ class YesRecord
4
+ using Refinements
5
+
6
+ class QueryNotFound < StandardError; end
7
+ class RecordNotFound < StandardError; end
8
+ Column = Struct.new("Column", :name, :type, :primary_key)
9
+ Query = Struct.new("Query", :name, :sql, :statement)
10
+
11
+ class << self
12
+ attr_writer :table_name
13
+
14
+ def table_name
15
+ @table_name || to_s.snake_case
16
+ end
17
+
18
+ def table(name)
19
+ @table_name = name
20
+ end
21
+
22
+ def schema
23
+ @schema ||= Yescode::Database.schema[table_name]
24
+ end
25
+
26
+ def columns
27
+ @columns ||= schema.map do |r|
28
+ Column.new(
29
+ r["columnName"],
30
+ r["columnType"],
31
+ r["pk"] == 1
32
+ )
33
+ end
34
+ end
35
+
36
+ def column_names
37
+ @column_names ||= columns.map(&:name)
38
+ end
39
+
40
+ def primary_key_column
41
+ @primary_key_column ||= columns.find(&:primary_key).first
42
+ end
43
+
44
+ def inspect
45
+ attr_str = columns.map { |c| " #{c.name} => #{c.type}" }.join("\n")
46
+
47
+ "#{self} {\n#{attr_str}\n}"
48
+ end
49
+
50
+ def connect
51
+ column_names.each do |column|
52
+ attr_accessor column.to_sym
53
+ end
54
+
55
+ self
56
+ end
57
+
58
+ def queries(filename)
59
+ @query_filename = filename
60
+ filepath = File.join(".", "app", "models", filename)
61
+ queries = Yescode::Queries.queries(filepath)
62
+ @queries = {}
63
+ queries.each do |name, sql|
64
+ @queries[name] = Query.new(name, sql)
65
+ end
66
+
67
+ @queries
68
+ end
69
+
70
+ def request_cache
71
+ Yescode::RequestCache.store[table_name] ||= {}
72
+ end
73
+
74
+ def clear_request_cache!
75
+ Yescode::RequestCache.store[table_name] = nil
76
+ end
77
+
78
+ def execute_query(name, *params)
79
+ query = @queries[name]
80
+ key = params
81
+ request_cache[name] ||= { key => nil }
82
+
83
+ raise QueryNotFound, "Can't find a query in #{@query_filename} with name #{name}" unless query
84
+
85
+ query.statement ||= Yescode::Database.connection.prepare(query.sql)
86
+ Yescode::Database.logger&.debug(cached: !request_cache.dig(name, key).nil?, sql: query.sql, params:)
87
+
88
+ request_cache[name][key] ||= query.statement.execute(*params).to_a
89
+ end
90
+
91
+ def first(name, *params)
92
+ row = execute_query(name, *params).first
93
+
94
+ new(row) if row
95
+ end
96
+
97
+ def first!(name, *params)
98
+ row = execute_query(name, *params).first
99
+
100
+ raise RecordNotFound unless row
101
+
102
+ new(row) if row
103
+ end
104
+
105
+ def select(name, *params)
106
+ rows = execute_query(name, *params)
107
+
108
+ rows.map { |r| new(r) }
109
+ end
110
+
111
+ def all(name = :all, *params)
112
+ select(name, *params)
113
+ end
114
+
115
+ def value(name, *params)
116
+ execute_query(name, *params).first&.values&.first
117
+ end
118
+
119
+ def count(name = :count, *params)
120
+ value(name, *params)
121
+ end
122
+
123
+ def values(keys)
124
+ if keys.empty?
125
+ "default values"
126
+ else
127
+ "values (#{keys.map(&:inspect).join(', ')})"
128
+ end
129
+ end
130
+
131
+ def insert_sql(keys)
132
+ "insert into #{table_name} (#{keys.join(', ')}) #{values(keys)} returning *"
133
+ end
134
+
135
+ def insert(params)
136
+ params.transform_keys!(&:to_sym)
137
+ params[:created_at] ||= Time.now.to_i if column_names.include?("created_at")
138
+ sql = insert_sql(params.keys)
139
+ inserted = Yescode::Database.get_first_row(sql, params)
140
+
141
+ clear_request_cache!
142
+
143
+ new(inserted)
144
+ rescue SQLite3::ConstraintException => e
145
+ Yescode::Database.logger.error(msg: e.message)
146
+
147
+ record = new(params)
148
+ record.rescue_constraint_error(e)
149
+
150
+ record
151
+ end
152
+
153
+ def insert_all
154
+ # TODO: do it
155
+ end
156
+
157
+ def update_all
158
+ # TODO: do it
159
+ end
160
+
161
+ def delete_all
162
+ result = Yescode::Database.execute "delete from #{table_name}"
163
+ clear_request_cache!
164
+
165
+ result
166
+ end
167
+ end
168
+
169
+ attr_accessor :errors
170
+
171
+ def initialize(args = {})
172
+ self.class.connect
173
+ load(args)
174
+ end
175
+
176
+ def load(args = {})
177
+ args.each do |k, v|
178
+ self.class.attr_accessor(k.to_sym) unless respond_to?(k.to_sym)
179
+ public_send("#{k}=", v)
180
+ end
181
+ end
182
+
183
+ def pk_column
184
+ self.class.primary_key_column.to_sym
185
+ end
186
+
187
+ def pk
188
+ to_h[pk_column]
189
+ end
190
+
191
+ def pk_param
192
+ { pk_column => pk }
193
+ end
194
+
195
+ def update_params(params)
196
+ params.transform_keys!(&:to_sym)
197
+ params[:updated_at] ||= Time.now.to_i if self.class.column_names.include?("updated_at")
198
+
199
+ params
200
+ end
201
+
202
+ def update_sql(keys)
203
+ set_clause = keys.map { |k| "#{k} = :#{k}" }.join(", ")
204
+
205
+ "update #{self.class.table_name} set #{set_clause} where #{pk_column} = :#{pk_column} returning *"
206
+ end
207
+
208
+ def update(params)
209
+ update_params = update_params(params)
210
+ sql = update_sql(update_params.keys)
211
+ update_params.merge!(pk_param)
212
+ updated = Yescode::Database.get_first_row(sql, update_params)
213
+ self.class.clear_request_cache!
214
+ load(updated)
215
+
216
+ true
217
+ rescue SQLite3::ConstraintException => e
218
+ Yescode::Database.logger.error(msg: e.message)
219
+ rescue_constraint_error(e)
220
+
221
+ false
222
+ end
223
+
224
+ def delete
225
+ sql = "delete from #{self.class.table_name} where #{pk_column} = ?"
226
+ Yescode::Database.execute(sql, pk)
227
+ self.class.clear_request_cache!
228
+
229
+ true
230
+ end
231
+
232
+ def check?(constraint_name)
233
+ return false unless @errors
234
+
235
+ @errors[:check]&.include?(constraint_name.to_s)
236
+ end
237
+
238
+ def null?(column_name)
239
+ return false unless @errors
240
+
241
+ @errors[:null]&.include?(column_name.to_s)
242
+ end
243
+
244
+ def duplicate?(column_name)
245
+ return false unless @errors
246
+
247
+ @errors[:unique]&.include?(column_name.to_s)
248
+ end
249
+
250
+ def error?(name)
251
+ check?(name) || null?(name) || duplicate?(name)
252
+ end
253
+
254
+ def errors?
255
+ @errors.any?
256
+ end
257
+
258
+ def rescue_constraint_error(error)
259
+ message = error.message
260
+ name = message.gsub(/(CHECK|NOT NULL|UNIQUE) constraint failed: (\w+\.)?/, '')
261
+ @errors ||= { check: [], null: [], unique: [] }
262
+
263
+ if message.start_with?("CHECK")
264
+ @errors[:check] << name
265
+ elsif message.start_with?("NOT NULL")
266
+ @errors[:null] << name
267
+ elsif message.start_with?("UNIQUE")
268
+ @errors[:unique] << name
269
+ end
270
+ end
271
+
272
+ def to_h
273
+ @to_h ||= self.class.column_names.map { |c| [c.to_sym, public_send(c)] }.to_h
274
+ end
275
+
276
+ def saved?
277
+ !pk.nil?
278
+ end
279
+
280
+ def to_param
281
+ to_h.slice(self.class.primary_key_column.to_sym)
282
+ end
283
+ end
data/lib/yes_routes.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class YesRoutes
4
+ using Refinements
5
+ class RouteDoesNotExistError < StandardError; end
6
+ class RouteParamsNilError < StandardError; end
7
+
8
+ class << self
9
+ attr_accessor :routes, :paths
10
+
11
+ def match(verb, path_string, class_name, method_name)
12
+ @paths ||= {}
13
+ @paths[[class_name, method_name]] = path_string
14
+
15
+ @routes ||= Hash.new { |hash, key| hash[key] = [] }
16
+ @routes[verb] << [path_string, class_name, method_name]
17
+ end
18
+
19
+ def path(class_name, method_name, params = {})
20
+ path_string = paths[[class_name, method_name]]
21
+
22
+ raise RouteParamsNilError, "Route params for :#{class_name}, :#{method_name} cannot be nil" if params.nil?
23
+
24
+ params = params.to_param if params.is_a?(YesRecord)
25
+
26
+ raise RouteDoesNotExistError, "Route for class #{class_name} and method #{method_name} doesn't exist" unless path_string
27
+
28
+ sub_params_in_path(path_string, params)
29
+ end
30
+
31
+ def get(path_string, class_name, method_name)
32
+ match("GET", path_string, class_name, method_name)
33
+ end
34
+
35
+ def post(path_string, class_name, method_name)
36
+ match("POST", path_string, class_name, method_name)
37
+ end
38
+
39
+ def resource(path_string, class_name)
40
+ snake_case = class_name.snake_case
41
+ [
42
+ [:get, "index", ""],
43
+ [:get, "new", "/new"],
44
+ [:post, "create", "/new"],
45
+ [:get, "show", "/:#{snake_case}"],
46
+ [:get, "edit", "/:#{snake_case}/edit"],
47
+ [:post, "update", "/:#{snake_case}/edit"],
48
+ [:post, "delete", "/:#{snake_case}/edit"]
49
+ ].each do |method, method_name, suffix|
50
+ public_send(method, [path_string, suffix].join("/").gsub(%r{/+}, "/"), class_name, method_name)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def sub_params_in_path(path, params = {})
57
+ params.transform_keys!(&:inspect)
58
+
59
+ re = Regexp.union(params.keys)
60
+
61
+ path.gsub(re, params)
62
+ end
63
+ end
64
+ end
data/lib/yes_static.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class YesStatic
4
+ def initialize(app, options = {})
5
+ @app = app
6
+ @root = options[:root] || Dir.pwd
7
+ end
8
+
9
+ def call(env)
10
+ ext = File.extname(env[Rack::PATH_INFO])
11
+
12
+ return @app.call(env) if ext.empty?
13
+
14
+ filepath = File.join(@root, env[Rack::PATH_INFO])
15
+
16
+ if File.exist?(filepath)
17
+ last_modified = ::File.mtime(filepath).httpdate
18
+ return [304, {}, []] if env["HTTP_IF_MODIFIED_SINCE"] == last_modified
19
+
20
+ [200, { "content-type" => Rack::Mime.mime_type(ext), "last-modified" => last_modified }, File.open(filepath)]
21
+ else
22
+ [404, {}, []]
23
+ end
24
+ end
25
+ end
data/lib/yes_view.rb ADDED
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ class YesView
4
+ using Refinements
5
+ include Emote::Helpers
6
+
7
+ class << self
8
+ attr_writer :template_path, :layout, :template_name
9
+ attr_accessor :paths, :logger
10
+
11
+ def template_path
12
+ @template_path || File.join(".", "app", "views")
13
+ end
14
+
15
+ def layout
16
+ @layout || "layout"
17
+ end
18
+
19
+ def template
20
+ File.join(template_path, template_name)
21
+ end
22
+
23
+ def view(template_name)
24
+ @template_name = template_name
25
+ end
26
+
27
+ def template_name
28
+ @template_name || "#{to_s.snake_case}.emote"
29
+ end
30
+ end
31
+
32
+ attr_accessor :csrf_name, :csrf_value, :assets, :session, :ajax
33
+
34
+ def template
35
+ self.class.template
36
+ end
37
+
38
+ def render(tmpl, params = {})
39
+ case tmpl
40
+ when YesView
41
+ tmpl.csrf_name = csrf_name
42
+ tmpl.csrf_value = csrf_value
43
+ tmpl.session = session
44
+ tmpl.assets = assets
45
+ tmpl.ajax = ajax
46
+ emote(tmpl.template, params, tmpl)
47
+ when String
48
+ emote(tmpl, params)
49
+ else
50
+ raise StandardError, "Unsupported template type passed to render"
51
+ end
52
+ end
53
+
54
+ def csrf_field
55
+ "<input type=\"hidden\" name=\"#{csrf_name}\" value=\"#{csrf_value}\" />"
56
+ end
57
+
58
+ def form(params = {})
59
+ params = { method: "post" }.merge(params)
60
+ attr_string = params.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
61
+
62
+ <<~HTML
63
+ <form #{attr_string}>
64
+ #{csrf_field}
65
+ HTML
66
+ end
67
+
68
+ def _form
69
+ "</form>"
70
+ end
71
+
72
+ def form_link(str, controller_name, method_name, params = {})
73
+ <<~HTML
74
+ #{form(action: path(controller_name, method_name, params))}
75
+ <input type="submit" value="#{str}" />
76
+ #{_form}
77
+ HTML
78
+ end
79
+
80
+ def css
81
+ return [] unless assets
82
+
83
+ assets["css"].map { |filename| "/css/#{filename}" }
84
+ end
85
+
86
+ def js
87
+ return [] unless assets
88
+
89
+ assets["js"].map { |filename| "/js/#{filename}" }
90
+ end
91
+
92
+ def path(class_name, method_name, params = {})
93
+ Object.const_get(:App).path(class_name, method_name, params)
94
+ end
95
+
96
+ def fetch(class_name, method_name, params = {})
97
+ "<div data-href=\"#{path(class_name, method_name, params)}\"></div>"
98
+ end
99
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yescode
4
+ class Asset
5
+ attr_reader :path
6
+
7
+ def initialize(filename, path:)
8
+ @filename = filename
9
+ @path = path
10
+ end
11
+
12
+ def full_path
13
+ File.join(path, @filename)
14
+ end
15
+
16
+ def content
17
+ File.read(full_path)
18
+ end
19
+
20
+ def ext
21
+ File.extname(@filename)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yescode
4
+ class AssetCompiler
5
+ class << self
6
+ def compile(assets, ext)
7
+ # read contents of each file into array of strings
8
+ combined = StringIO.open do |s|
9
+ assets[ext].each do |filename|
10
+ source = File.join(".", "app", ext, filename)
11
+ s.puts File.read(source)
12
+ end
13
+
14
+ s.string
15
+ end
16
+
17
+ # hash the contents of each concatenated asset
18
+ hash = Digest::SHA1.hexdigest combined
19
+
20
+ # use hash to bust the cache
21
+ name = "#{hash}.#{ext}"
22
+ filename = File.join(".", "public", ext, name)
23
+ FileUtils.mkdir_p(File.join(".", "public", ext))
24
+
25
+ # output asset path for browser
26
+ # returns string of written filename
27
+ File.write(filename, combined)
28
+
29
+ name
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yescode
4
+ class Assets
5
+ def initialize
6
+ @assets_path = File.join(".", "app")
7
+ @assets = Hash.new { |hash, key| hash[key] = [] }
8
+ end
9
+
10
+ def css(filenames = [])
11
+ @assets["css"] = filenames
12
+ end
13
+
14
+ def js(filenames = [])
15
+ @assets["js"] = filenames
16
+ end
17
+
18
+ def compile_assets
19
+ css = AssetCompiler.compile(@assets, "css") unless @assets["css"].empty?
20
+ js = AssetCompiler.compile(@assets, "js") unless @assets["js"].empty?
21
+
22
+ @assets["css"] = [css]
23
+ @assets["js"] = [js]
24
+ end
25
+
26
+ def to_h
27
+ @assets
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yescode
4
+ class Database
5
+ class << self
6
+ attr_writer :connection_string
7
+ attr_accessor :logger
8
+
9
+ def connection_string
10
+ @connection_string || ENV["DATABASE_URL"]
11
+ end
12
+
13
+ def connection
14
+ @connection ||= SQLite3::Database.new(connection_string, { results_as_hash: true })
15
+ end
16
+
17
+ def version(filename)
18
+ File.basename(filename).split("_").first.to_i
19
+ end
20
+
21
+ def migrate(filenames)
22
+ execute "create table if not exists yescode_migrations ( version integer primary key )"
23
+ rows = execute("select version from yescode_migrations").map { |r| r["version"] }
24
+ file_versions = filenames.map { |f| version(f) }
25
+
26
+ return if (file_versions - rows).empty?
27
+
28
+ transaction do
29
+ filenames.each do |filename|
30
+ version = version(filename)
31
+ next if rows.include?(version)
32
+
33
+ queries = Queries.queries(filename)
34
+ execute(queries.find { |name, _| name == :up }[1])
35
+ execute "insert into yescode_migrations (version) values (?)", version
36
+ end
37
+ end
38
+ end
39
+
40
+ def rollback_schema(filenames, step: 1)
41
+ execute "create table if not exists yescode_migrations ( version integer primary key )"
42
+ rows = execute("select version from yescode_migrations order by version desc limit #{step}").map { |r| r["version"] }
43
+
44
+ transaction do
45
+ rows.each do |row|
46
+ filename = filenames.find { |f| version(f) == row }
47
+ version = version(filename)
48
+ next unless rows.include?(version)
49
+
50
+ queries = Queries.queries(filename)
51
+ execute(queries.find { |name, _| name == :down }[1])
52
+ execute "delete from yescode_migrations where version = ?", version
53
+ end
54
+ end
55
+ end
56
+
57
+ def transaction(mode = :deferred)
58
+ return yield(connection) if connection.transaction_active?
59
+
60
+ execute "begin #{mode} transaction"
61
+ @abort = false
62
+ yield self
63
+
64
+ true
65
+ rescue StandardError
66
+ @abort = true
67
+ raise
68
+ ensure
69
+ if @abort
70
+ execute "rollback transaction"
71
+ else
72
+ execute "commit transaction"
73
+ end
74
+ end
75
+
76
+ def rollback
77
+ @abort = true
78
+ end
79
+
80
+ def execute(sql, params = nil)
81
+ logger&.debug(sql:, params:)
82
+
83
+ connection.execute sql, params || []
84
+ end
85
+
86
+ def get_first_value(sql, params = nil)
87
+ logger&.debug(sql:, params:)
88
+
89
+ connection.get_first_value sql, params || []
90
+ end
91
+
92
+ def get_first_row(sql, params = nil)
93
+ logger&.debug(sql:, params:)
94
+
95
+ connection.get_first_row sql, params || []
96
+ end
97
+
98
+ def schema
99
+ return @schema if @schema
100
+
101
+ sql = <<-SQL
102
+ select
103
+ m.name as tableName,
104
+ pti.name as columnName,
105
+ pti.type as columnType,
106
+ m.sql as sql,
107
+ pti.pk as pk
108
+ from
109
+ sqlite_master m
110
+ left outer join
111
+ pragma_table_info(m.name) pti on pti.name <> m.name
112
+ where
113
+ m.type == 'table'
114
+ order by
115
+ tableName,
116
+ columnName
117
+ SQL
118
+
119
+ rows = Database.execute sql
120
+
121
+ @schema = rows.group_by do |r|
122
+ r["tableName"]
123
+ end
124
+ end
125
+ end
126
+
127
+ if connection_string && connection
128
+ execute "PRAGMA journal_model = WAL"
129
+ execute "PRAGMA foreign_keys = ON"
130
+ execute "PRAGMA busy_timeout = 5000"
131
+ execute "PRAGMA synchronous = NORMAL"
132
+ execute "PRAGMA wal_autocheckpoint = 0"
133
+ schema
134
+ end
135
+ end
136
+ end