radical 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +0 -2
  3. data/CHANGELOG.md +46 -1
  4. data/Gemfile +4 -2
  5. data/README.md +5 -1
  6. data/exe/rad +41 -0
  7. data/lib/radical/app.rb +61 -27
  8. data/lib/radical/asset.rb +24 -0
  9. data/lib/radical/asset_compiler.rb +40 -0
  10. data/lib/radical/assets.rb +45 -0
  11. data/lib/radical/controller.rb +54 -11
  12. data/lib/radical/database.rb +43 -44
  13. data/lib/radical/env.rb +1 -0
  14. data/lib/radical/flash.rb +61 -0
  15. data/lib/radical/form.rb +75 -15
  16. data/lib/radical/generator/app/.env +5 -0
  17. data/lib/radical/generator/app/Gemfile +7 -0
  18. data/lib/radical/generator/app/app.rb +37 -0
  19. data/lib/radical/generator/app/config.ru +5 -0
  20. data/lib/radical/generator/app/controllers/controller.rb +4 -0
  21. data/lib/radical/generator/app/models/model.rb +4 -0
  22. data/lib/radical/generator/app/routes.rb +5 -0
  23. data/lib/radical/generator/blank_migration.rb +11 -0
  24. data/lib/radical/generator/controller.rb +59 -0
  25. data/lib/radical/generator/migration.rb +13 -0
  26. data/lib/radical/generator/model.rb +9 -0
  27. data/lib/radical/generator/views/_form.rb +6 -0
  28. data/lib/radical/generator/views/edit.rb +3 -0
  29. data/lib/radical/generator/views/index.rb +24 -0
  30. data/lib/radical/generator/views/new.rb +4 -0
  31. data/lib/radical/generator/views/show.rb +5 -0
  32. data/lib/radical/generator.rb +155 -0
  33. data/lib/radical/migration.rb +45 -0
  34. data/lib/radical/model.rb +3 -12
  35. data/lib/radical/router.rb +143 -42
  36. data/lib/radical/routes.rb +59 -0
  37. data/lib/radical/security_headers.rb +27 -0
  38. data/lib/radical/strings.rb +17 -0
  39. data/lib/radical/table.rb +2 -0
  40. data/lib/radical/view.rb +19 -9
  41. data/lib/radical.rb +11 -0
  42. data/radical.gemspec +4 -3
  43. metadata +44 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 537aa5f33439e08833e0aca3ff572a0e017c20cdbfaa3d267c303191cc692fd0
4
- data.tar.gz: 95ee9c628803bf81f237d15aa6c3d92a1d0c55f4412809eb4e74a57dd510e15c
3
+ metadata.gz: 3d14c9a576701105d60a08c6339a19aeebc080b6a4fd6f7235d8c5dec82ea287
4
+ data.tar.gz: b03f1d945e0f441401d99375062423dff70fa9c50cc394e470444d253faa46c4
5
5
  SHA512:
6
- metadata.gz: f30a37e86df78f5beb7a345b96031a1d83779a723629a7b1ca3c70223d7cea94acfc277bea77d32b8461382fbed2f146b3bf69a732a77224fd4d586f7877811d
7
- data.tar.gz: a322aef87deae2b114842c4a88ac31fc02f35258701770d9a2017158616a6d07229a5960de8879efbd403b286d4c1a53ec64ba7f9b7e202570734f8b9de0c5b1
6
+ metadata.gz: cbdbdd7c8a1a56755ef772bbd7ce24a197111d97d44be0d1c1f6a56be24361d1f80469583f4969c0d8b8d9500ce92a66333e71539e8a79b845f249659b72a6ec
7
+ data.tar.gz: d3425aef0d2a3d87788b7169ba81b67b8c71faed045227aaff586a699ad46482d0770cfaacf0a738d787bb28b20be42c47b7d88ef79eda5632af5abe91da1e1b
data/.rubocop.yml CHANGED
@@ -1,4 +1,2 @@
1
- Style/FrozenStringLiteralComment:
2
- Enabled: false
3
1
  Style/Documentation:
4
2
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,9 +1,50 @@
1
1
  # Changelog
2
+
2
3
  All notable changes to this project will be documented in this file
3
4
 
4
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
5
6
 
6
- ## [Unreleased]
7
+ # [Unreleased]
8
+
9
+ # 1.2.0 (2021-12-17)
10
+
11
+ - *Breaking* Support only one level of nested resources, make them shallow only
12
+ - *Breaking* Move `db/migrations` to `migrations`
13
+ - *Breaking* Change `<%== f.button 'Save' %>` to `<%== f.submit 'Save' %>`
14
+ - *Breaking* Change the form helpers from `model[name]` to just `name`, it's cleaner.
15
+ - Add `Radical.env.[development?|production?|test?]`
16
+ - Add `_path` support for nested resource routes
17
+ - Add a random secret to the session cookie on startup
18
+ - Add asset concatention + compression (no minification yet)
19
+ - Remove dependence on rack-flash3
20
+ - Add security headers
21
+ - Add session configuration with `session` method in `App`
22
+ - Move all `_path` method definitions to `Radical::Controller`
23
+ - Add `exe/rad`
24
+ - Add `rad g mvc Todo(s) name:text done_at:integer` generators
25
+ - Add attrs to form helpers
26
+ - Add `rad g app` generator
27
+ - Add migrate/rollback `rad` commands
28
+
29
+ # 1.1.0 (2021-12-06)
30
+
31
+ - *Breaking* `root`, `resource` and `resources` methods no longer take class constants
32
+ - *Breaking* Move route class methods to `Routes` class instead of `App`
33
+ - *Breaking* Create migration class, use migration classes in migrate!
34
+ - *Breaking* Make routes take symbols or strings, not classes to better line up with models
35
+ - *Breaking* Move connection string handling to database
36
+ - Make everything use `frozen_string_literal`
37
+ - Purposefully never add callbacks, `before_action` or autoloading
38
+
39
+ # 1.0.2 (2021-12-02)
40
+
41
+ - Set default views / migrations paths
42
+
43
+ # 1.0.1 (2021-12-02)
44
+
45
+ - Fix changelog link in gemspec
46
+
47
+ # 1.0.0 (2021-12-01)
7
48
 
8
49
  - Very basic understanding of how much memory it takes for a basic ruby app
9
50
  - Start to integrate controllers and routing
@@ -25,3 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
25
66
  - Use Rack::Test
26
67
  - Rename routes to resources; add resource method
27
68
  - Add nested resources, multi-argument resources
69
+ - Use rack sessions, rack-csrf and rack-flash3
70
+ - Add naive migrations
71
+ - Add naive models
72
+ - Add naive form helper
data/Gemfile CHANGED
@@ -1,5 +1,7 @@
1
- source "https://rubygems.org"
1
+ # frozen_string_literal: true
2
2
 
3
- git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
6
 
5
7
  gemspec
data/README.md CHANGED
@@ -27,8 +27,12 @@ class Home < Radical::Controller
27
27
  end
28
28
  end
29
29
 
30
+ class Routes < Radical::Routes
31
+ root :Home
32
+ end
33
+
30
34
  class App < Radical::App
31
- root Home
35
+ routes Routes
32
36
  end
33
37
 
34
38
  run App
data/exe/rad ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/radical/generator'
4
+ require_relative '../lib/radical/database'
5
+ require 'optparse'
6
+
7
+ @options = {}
8
+
9
+ OptionParser.new do |opts|
10
+ opts.on('-v', '--verbose', 'Show extra information') do
11
+ @options[:verbose] = true
12
+ end
13
+
14
+ opts.on('-h', '--help', 'Show help information') do
15
+ @options[:help] = true
16
+ end
17
+ end.parse!
18
+
19
+ generate = Radical::Generator.new(ARGV[2], ARGV.drop(3)) if %w[g generate].include?(ARGV.first)
20
+
21
+ case ARGV[0..1]
22
+ when %w[g mvc], %w[generate mvc]
23
+ generate.mvc
24
+ when %w[g model], %w[generate model]
25
+ generate.migration
26
+ generate.model
27
+ when %w[g controller], %w[generate controller]
28
+ generate.controller
29
+ when %w[g views], %w[generate views]
30
+ generate.views
31
+ when %w[g migration], %w[generate migration]
32
+ generate.migration(model: false)
33
+ when %w[g app]
34
+ generate.app
35
+ when %w[migrate]
36
+ Radical::Database.migrate!
37
+ when %w[rollback]
38
+ Radical::Database.rollback!
39
+ else
40
+ puts 'Command not supported'
41
+ end
data/lib/radical/app.rb CHANGED
@@ -1,9 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
1
4
  require 'rack'
2
- require 'rack/flash'
3
5
  require 'rack/csrf'
4
6
 
5
- require_relative 'router'
7
+ require_relative 'asset'
8
+ require_relative 'assets'
9
+ require_relative 'asset_compiler'
6
10
  require_relative 'env'
11
+ require_relative 'flash'
12
+ require_relative 'routes'
13
+ require_relative 'security_headers'
7
14
 
8
15
  # The main entry point for a Radical application
9
16
  #
@@ -31,24 +38,39 @@ require_relative 'env'
31
38
  module Radical
32
39
  class App
33
40
  class << self
34
- def root(klass)
35
- router.add_root(klass)
41
+ def routes(route_class)
42
+ @routes = route_class
36
43
  end
37
44
 
38
- def resource(klass)
39
- router.add_actions(klass, actions: Router::RESOURCE_ACTIONS)
45
+ def assets(&block)
46
+ @assets = Assets.new
47
+
48
+ block.call(@assets)
40
49
  end
41
50
 
42
- def resources(*classes, &block)
43
- prefix = "#{router.route_prefix(@parents)}/" if instance_variable_defined?(:@parents)
51
+ def compile_assets
52
+ @assets.compile
53
+ end
44
54
 
45
- router.add_routes(classes, prefix: prefix)
55
+ def serve_assets
56
+ @serve_assets = true
57
+ end
46
58
 
47
- return unless block
59
+ def security_headers(headers = {})
60
+ @security_headers = headers
61
+ end
62
+
63
+ def session(options = {})
64
+ defaults = {
65
+ path: '/',
66
+ secret: session_secret,
67
+ http_only: true,
68
+ same_site: :lax,
69
+ secure: env.production?,
70
+ expire_after: 2_592_000 # 30 days
71
+ }
48
72
 
49
- @parents ||= []
50
- @parents << classes.last
51
- block.call
73
+ @session = defaults.merge(options)
52
74
  end
53
75
 
54
76
  def env
@@ -56,8 +78,12 @@ module Radical
56
78
  end
57
79
 
58
80
  def app
59
- router = self.router
81
+ router = @routes.router
60
82
  env = self.env
83
+ assets = @assets
84
+ serve_assets = @serve_assets
85
+ security_headers = @security_headers || {}
86
+ session = @session || self.session
61
87
 
62
88
  @app ||= Rack::Builder.app do
63
89
  use Rack::CommonLogger
@@ -65,23 +91,29 @@ module Radical
65
91
  use Rack::Runtime
66
92
  use Rack::MethodOverride
67
93
  use Rack::ContentLength
68
- use Rack::Deflater
69
94
  use Rack::ETag
95
+ use Rack::Deflater
70
96
  use Rack::Head
71
97
  use Rack::ConditionalGet
72
98
  use Rack::ContentType
73
- use Rack::Session::Cookie, path: '/',
74
- secret: ENV['SESSION_SECRET'],
75
- http_only: true,
76
- same_site: :lax,
77
- secure: env.production?,
78
- expire_after: 2_592_000 # 30 days
99
+ use Rack::Session::Cookie, session
79
100
  use Rack::Csrf, raise: env.development?, skip: router.routes.values.flatten.select { |a| a.is_a?(Class) }.uniq.map(&:skip_csrf_actions).flatten(1)
80
- use Rack::Flash, sweep: true
101
+ use Flash
102
+ use SecurityHeaders, security_headers
103
+
104
+ if serve_assets || env.development?
105
+ use Rack::Static, urls: ['/assets', '/public'],
106
+ header_rules: [
107
+ [/\.(?:css\.gz)$/, { 'Content-Type' => 'text/css', 'Content-Encoding' => 'gzip' }],
108
+ [/\.(?:js\.gz)$/, { 'Content-Type' => 'application/javascript', 'Content-Encoding' => 'gzip' }],
109
+ [/\.(?:css\.br)$/, { 'Content-Type' => 'text/css', 'Content-Encoding' => 'br' }],
110
+ [/\.(?:js\.br)$/, { 'Content-Type' => 'application/javascript', 'Content-Encoding' => 'br' }]
111
+ ]
112
+ end
81
113
 
82
114
  run lambda { |rack_env|
83
115
  begin
84
- router.route(Rack::Request.new(rack_env)).finish
116
+ router.route(Rack::Request.new(rack_env), options: { assets: assets }).finish
85
117
  rescue ModelNotFound
86
118
  raise unless env.production?
87
119
 
@@ -91,13 +123,15 @@ module Radical
91
123
  end
92
124
  end
93
125
 
94
- def router
95
- @router ||= Router.new
96
- end
97
-
98
126
  def call(env)
99
127
  app.call(env)
100
128
  end
129
+
130
+ private
131
+
132
+ def session_secret
133
+ @session_secret ||= (ENV['SESSION_SECRET'] || SecureRandom.hex(32))
134
+ end
101
135
  end
102
136
  end
103
137
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'brotli'
4
+ require 'digest'
5
+ require 'zlib'
6
+
7
+ module Radical
8
+ class AssetCompiler
9
+ def self.gzip(filename, content)
10
+ # nil, 31 == support for gunzip
11
+ File.write(filename, Zlib::Deflate.new(nil, 31).deflate(content, Zlib::FINISH))
12
+ end
13
+
14
+ def self.compile(assets, path:, compressor: :none)
15
+ s = assets.map(&:content).join("\n")
16
+ ext = assets.first.ext
17
+
18
+ # hash the contents of each concatenated asset
19
+ hash = Digest::SHA1.hexdigest s
20
+
21
+ # use hash to bust the cache
22
+ name = "#{hash}#{ext}"
23
+ filename = File.join(path, name)
24
+
25
+ case compressor
26
+ when :gzip
27
+ name = "#{name}.gz"
28
+ gzip("#{filename}.gz", s)
29
+ when :brotli
30
+ name = "#{name}.br"
31
+ File.write("#{filename}.br", Brotli.deflate(s, mode: :text, quality: 11))
32
+ else
33
+ File.write(filename, s)
34
+ end
35
+
36
+ # output asset path for browser
37
+ "/assets/#{name}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radical
4
+ class Assets
5
+ attr_accessor :assets_path, :compiled, :assets
6
+
7
+ def initialize
8
+ @assets = {
9
+ css: [],
10
+ js: []
11
+ }
12
+ @compressor = :none
13
+ @assets_path = File.join(__dir__, 'assets')
14
+ @compiled = {}
15
+ end
16
+
17
+ def css(filenames)
18
+ @assets[:css] = filenames
19
+ end
20
+
21
+ def js(filenames)
22
+ @assets[:js] = filenames
23
+ end
24
+
25
+ def prepend_assets_path(path)
26
+ @assets_path = File.join(path, 'assets')
27
+ end
28
+
29
+ def brotli
30
+ @compressor = :brotli
31
+ end
32
+
33
+ def gzip
34
+ @compressor = :gzip
35
+ end
36
+
37
+ def compile
38
+ css = @assets[:css].map { |f| Asset.new(f, path: File.join(@assets_path, 'css')) }
39
+ js = @assets[:js].map { |f| Asset.new(f, path: File.join(@assets_path, 'js')) }
40
+
41
+ @compiled[:css] = AssetCompiler.compile(css, path: @assets_path, compressor: @compressor)
42
+ @compiled[:js] = AssetCompiler.compile(js, path: @assets_path, compressor: @compressor)
43
+ end
44
+ end
45
+ end
@@ -1,4 +1,6 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
3
+
2
4
  require 'rack/utils'
3
5
  require 'rack/request'
4
6
  require 'rack/response'
@@ -29,7 +31,7 @@ module Radical
29
31
 
30
32
  sig { returns(String) }
31
33
  def route_name
32
- to_s.split('::').last.gsub(/Controller$/, '').gsub(/([A-Z])/, '_\1')[1..-1].downcase
34
+ Strings.snake_case to_s.split('::').last.gsub(/Controller$/, '')
33
35
  end
34
36
 
35
37
  sig { params(actions: Symbol).void }
@@ -70,9 +72,12 @@ module Radical
70
72
  end
71
73
  end
72
74
 
73
- sig { params(request: Rack::Request).void }
74
- def initialize(request)
75
+ attr_reader :options
76
+
77
+ sig { params(request: Rack::Request, options: T.nilable(Hash)).void }
78
+ def initialize(request, options: {})
75
79
  @request = request
80
+ @options = options
76
81
  end
77
82
 
78
83
  sig { params(status: T.any(Symbol, Integer)).returns(Rack::Response) }
@@ -90,14 +95,14 @@ module Radical
90
95
  @request.params
91
96
  end
92
97
 
93
- sig { params(name: T.any(String, Symbol)).returns(String) }
94
- def view(name)
95
- View.render(self.class.route_name, name, self)
98
+ sig { params(name: T.any(String, Symbol), locals: T.nilable(Hash)).returns(String) }
99
+ def view(name, locals = {})
100
+ View.render(self.class.route_name, name, self, { locals: locals })
96
101
  end
97
102
 
98
- sig { params(name: T.any(String, Symbol)).returns(String) }
99
- def partial(name)
100
- View.render(self.class.route_name, "_#{name}", self, layout: false)
103
+ sig { params(name: T.any(String, Symbol), locals: T.nilable(Hash)).returns(String) }
104
+ def partial(name, locals = {})
105
+ View.render(self.class.route_name, "_#{name}", self, { locals: locals, layout: false })
101
106
  end
102
107
 
103
108
  sig { params(options: Hash, block: T.proc.void).returns(String) }
@@ -128,17 +133,55 @@ module Radical
128
133
  @request.env['rack.session']
129
134
  end
130
135
 
136
+ def assets_path(type)
137
+ assets = options[:assets]
138
+
139
+ if Env.production?
140
+ compiled_assets_path(assets, type)
141
+ else
142
+ not_compiled_assets_path(assets, type)
143
+ end
144
+ end
145
+
146
+ def compiled_assets_path(assets, type)
147
+ if type == :css
148
+ link_tag(assets.compiled[:css])
149
+ else
150
+ script_tag(assets.compiled[:js])
151
+ end
152
+ end
153
+
154
+ def not_compiled_assets_path(assets, type)
155
+ if type == :css
156
+ assets.assets[:css].map do |asset|
157
+ link_tag("/assets/#{type}/#{asset}")
158
+ end.join("\n")
159
+ else
160
+ assets.assets[:js].map do |asset|
161
+ script_tag("/assets/#{type}/#{asset}")
162
+ end.join("\n")
163
+ end
164
+ end
165
+
131
166
  private
132
167
 
133
168
  def emit(tag)
134
- @output = '' if @output.nil?
169
+ @output = String.new if @output.nil?
135
170
  @output << tag.to_s
136
171
  end
137
172
 
138
173
  def capture(block)
139
- @output = eval('_buf', block.binding)
174
+ @output = eval '_buf', block.binding
140
175
  yield
141
176
  @output
142
177
  end
178
+
179
+ def script_tag(src)
180
+ "<script type=\"application/javascript\" src=\"#{src}\"></script>"
181
+ end
182
+
183
+ def link_tag(href)
184
+ "<link rel=\"stylesheet\" type=\"text/css\" href=\"#{href}\" />"
185
+ end
143
186
  end
144
187
  end
@@ -1,40 +1,67 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sqlite3'
2
4
  require_relative 'table'
5
+ require_relative 'migration'
3
6
 
4
7
  module Radical
5
8
  class Database
6
9
  class << self
7
- attr_accessor :connection, :migrations_path
10
+ attr_writer :connection_string
11
+ attr_accessor :migrations_path
12
+
13
+ def connection_string
14
+ @connection_string || ENV['DATABASE_URL']
15
+ end
16
+
17
+ def connection
18
+ conn = SQLite3::Database.new(connection_string)
19
+ conn.results_as_hash = true
20
+ conn.type_translation = true
21
+
22
+ @connection ||= conn
23
+ end
24
+
25
+ def prepend_migrations_path(path)
26
+ self.migrations_path = path
27
+ end
8
28
 
9
29
  def db
10
30
  connection
11
31
  end
12
32
 
13
- def migrate!
14
- @migrate = true
33
+ def migration(file)
34
+ context = Module.new
35
+ context.class_eval(File.read(file), file)
36
+ const = context.constants.find do |constant|
37
+ context.const_get(constant).ancestors.include?(Radical::Migration)
38
+ end
39
+
40
+ context.const_get(const)
41
+ end
15
42
 
43
+ def migrate!
16
44
  db.execute 'create table if not exists radical_migrations ( version integer primary key )'
17
45
 
18
- pending_migrations.each do |migration|
19
- puts "Executing migration #{migration}"
20
- sql = eval File.read(migration)
21
- db.execute sql
22
- db.execute 'insert into radical_migrations (version) values (?)', [version(migration)]
46
+ pending_migrations.each do |file|
47
+ puts "Executing migration #{file}"
48
+
49
+ v = version(file)
50
+
51
+ migration(file).migrate!(db: db, version: v)
23
52
  end
24
53
  end
25
54
 
26
55
  def rollback!
27
- @rollback = true
28
-
29
56
  db.execute 'create table if not exists radical_migrations ( version integer primary key )'
30
57
 
31
- migration = applied_migrations.last
58
+ file = applied_migrations.last
32
59
 
33
- puts "Rolling back migration #{migration}"
60
+ puts "Rolling back migration #{file}"
34
61
 
35
- sql = eval File.read(migration)
36
- db.execute sql
37
- db.execute 'delete from radical_migrations where version = ?', [version(migration)]
62
+ v = version(file)
63
+
64
+ migration(file).rollback!(db: db, version: v)
38
65
  end
39
66
 
40
67
  def applied_versions
@@ -53,40 +80,12 @@ module Radical
53
80
  end
54
81
 
55
82
  def migrations
56
- Dir[File.join(migrations_path, 'db', 'migrations', '*.rb')].sort
83
+ Dir[File.join(migrations_path || '.', 'migrations', '*.rb')].sort
57
84
  end
58
85
 
59
86
  def version(filename)
60
87
  filename.split(File::SEPARATOR).last.split('_').first.to_i
61
88
  end
62
-
63
- def migration(&block)
64
- block.call
65
- end
66
-
67
- def change(&block)
68
- @change = true
69
-
70
- block.call
71
- end
72
-
73
- def up(&block)
74
- block.call
75
- end
76
-
77
- def down(&block)
78
- block.call
79
- end
80
-
81
- def create_table(name, &block)
82
- return "drop table #{name}" if @change && @rollback
83
-
84
- table = Table.new(name)
85
-
86
- block.call(table)
87
-
88
- "create table #{name} ( id integer primary key, #{table.columns.join(',')} )"
89
- end
90
89
  end
91
90
  end
92
91
  end
data/lib/radical/env.rb CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # typed: true
2
3
 
3
4
  module Radical