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