tina4 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +662 -0
- data/exe/tina4 +4 -0
- data/lib/tina4/api.rb +152 -0
- data/lib/tina4/auth.rb +139 -0
- data/lib/tina4/cli.rb +243 -0
- data/lib/tina4/crud.rb +124 -0
- data/lib/tina4/database.rb +135 -0
- data/lib/tina4/database_result.rb +89 -0
- data/lib/tina4/debug.rb +83 -0
- data/lib/tina4/dev_reload.rb +68 -0
- data/lib/tina4/drivers/firebird_driver.rb +94 -0
- data/lib/tina4/drivers/mssql_driver.rb +112 -0
- data/lib/tina4/drivers/mysql_driver.rb +90 -0
- data/lib/tina4/drivers/postgres_driver.rb +99 -0
- data/lib/tina4/drivers/sqlite_driver.rb +85 -0
- data/lib/tina4/env.rb +55 -0
- data/lib/tina4/field_types.rb +84 -0
- data/lib/tina4/localization.rb +100 -0
- data/lib/tina4/middleware.rb +59 -0
- data/lib/tina4/migration.rb +124 -0
- data/lib/tina4/orm.rb +168 -0
- data/lib/tina4/queue.rb +117 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
- data/lib/tina4/queue_backends/lite_backend.rb +79 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
- data/lib/tina4/rack_app.rb +150 -0
- data/lib/tina4/request.rb +158 -0
- data/lib/tina4/response.rb +172 -0
- data/lib/tina4/router.rb +142 -0
- data/lib/tina4/scss_compiler.rb +131 -0
- data/lib/tina4/session.rb +145 -0
- data/lib/tina4/session_handlers/file_handler.rb +55 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
- data/lib/tina4/session_handlers/redis_handler.rb +43 -0
- data/lib/tina4/swagger.rb +123 -0
- data/lib/tina4/template.rb +478 -0
- data/lib/tina4/templates/base.twig +25 -0
- data/lib/tina4/templates/errors/403.twig +22 -0
- data/lib/tina4/templates/errors/404.twig +22 -0
- data/lib/tina4/templates/errors/500.twig +22 -0
- data/lib/tina4/testing.rb +213 -0
- data/lib/tina4/version.rb +5 -0
- data/lib/tina4/webserver.rb +101 -0
- data/lib/tina4/websocket.rb +167 -0
- data/lib/tina4/wsdl.rb +164 -0
- data/lib/tina4.rb +233 -0
- metadata +303 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Drivers
|
|
5
|
+
class PostgresDriver
|
|
6
|
+
attr_reader :connection
|
|
7
|
+
|
|
8
|
+
def connect(connection_string)
|
|
9
|
+
require "pg"
|
|
10
|
+
@connection = PG.connect(connection_string)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def close
|
|
14
|
+
@connection&.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute_query(sql, params = [])
|
|
18
|
+
converted_sql = convert_placeholders(sql)
|
|
19
|
+
result = if params.empty?
|
|
20
|
+
@connection.exec(converted_sql)
|
|
21
|
+
else
|
|
22
|
+
@connection.exec_params(converted_sql, params)
|
|
23
|
+
end
|
|
24
|
+
result.map { |row| symbolize_keys(row) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def execute(sql, params = [])
|
|
28
|
+
converted_sql = convert_placeholders(sql)
|
|
29
|
+
if params.empty?
|
|
30
|
+
@connection.exec(converted_sql)
|
|
31
|
+
else
|
|
32
|
+
@connection.exec_params(converted_sql, params)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def last_insert_id
|
|
37
|
+
result = @connection.exec("SELECT lastval()")
|
|
38
|
+
result.first["lastval"].to_i
|
|
39
|
+
rescue PG::Error
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def placeholder
|
|
44
|
+
"?"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def placeholders(count)
|
|
48
|
+
(1..count).map { |i| "$#{i}" }.join(", ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_limit(sql, limit, offset = 0)
|
|
52
|
+
"#{sql} LIMIT #{limit} OFFSET #{offset}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def begin_transaction
|
|
56
|
+
@connection.exec("BEGIN")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def commit
|
|
60
|
+
@connection.exec("COMMIT")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rollback
|
|
64
|
+
@connection.exec("ROLLBACK")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tables
|
|
68
|
+
sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
|
69
|
+
rows = execute_query(sql)
|
|
70
|
+
rows.map { |r| r[:tablename] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def columns(table_name)
|
|
74
|
+
sql = "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1"
|
|
75
|
+
rows = execute_query(sql, [table_name])
|
|
76
|
+
rows.map do |r|
|
|
77
|
+
{
|
|
78
|
+
name: r[:column_name],
|
|
79
|
+
type: r[:data_type],
|
|
80
|
+
nullable: r[:is_nullable] == "YES",
|
|
81
|
+
default: r[:column_default],
|
|
82
|
+
primary_key: false
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def convert_placeholders(sql)
|
|
90
|
+
counter = 0
|
|
91
|
+
sql.gsub("?") { counter += 1; "$#{counter}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def symbolize_keys(hash)
|
|
95
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Drivers
|
|
5
|
+
class SqliteDriver
|
|
6
|
+
attr_reader :connection
|
|
7
|
+
|
|
8
|
+
def connect(connection_string)
|
|
9
|
+
require "sqlite3"
|
|
10
|
+
db_path = connection_string.sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
|
|
11
|
+
@connection = SQLite3::Database.new(db_path)
|
|
12
|
+
@connection.results_as_hash = true
|
|
13
|
+
@connection.execute("PRAGMA journal_mode=WAL")
|
|
14
|
+
@connection.execute("PRAGMA foreign_keys=ON")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def close
|
|
18
|
+
@connection&.close
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute_query(sql, params = [])
|
|
22
|
+
results = @connection.execute(sql, params)
|
|
23
|
+
results.map { |row| symbolize_keys(row) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def execute(sql, params = [])
|
|
27
|
+
@connection.execute(sql, params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def last_insert_id
|
|
31
|
+
@connection.last_insert_row_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def placeholder
|
|
35
|
+
"?"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def placeholders(count)
|
|
39
|
+
(["?"] * count).join(", ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_limit(sql, limit, offset = 0)
|
|
43
|
+
"#{sql} LIMIT #{limit} OFFSET #{offset}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def begin_transaction
|
|
47
|
+
@connection.execute("BEGIN TRANSACTION")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def commit
|
|
51
|
+
@connection.execute("COMMIT")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rollback
|
|
55
|
+
@connection.execute("ROLLBACK")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tables
|
|
59
|
+
rows = execute_query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
60
|
+
rows.map { |r| r[:name] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def columns(table_name)
|
|
64
|
+
rows = execute_query("PRAGMA table_info(#{table_name})")
|
|
65
|
+
rows.map do |r|
|
|
66
|
+
{
|
|
67
|
+
name: r[:name],
|
|
68
|
+
type: r[:type],
|
|
69
|
+
nullable: r[:notnull] == 0,
|
|
70
|
+
default: r[:dflt_value],
|
|
71
|
+
primary_key: r[:pk] == 1
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def symbolize_keys(hash)
|
|
79
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
80
|
+
h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/tina4/env.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
module Env
|
|
6
|
+
DEFAULT_ENV = {
|
|
7
|
+
"PROJECT_NAME" => "Tina4 Ruby Project",
|
|
8
|
+
"VERSION" => "1.0.0",
|
|
9
|
+
"TINA4_LANGUAGE" => "en",
|
|
10
|
+
"TINA4_DEBUG_LEVEL" => "[TINA4_LOG_ALL]",
|
|
11
|
+
"SECRET" => "tina4-secret-change-me"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def load(root_dir = Dir.pwd)
|
|
16
|
+
env_file = resolve_env_file(root_dir)
|
|
17
|
+
unless File.exist?(env_file)
|
|
18
|
+
create_default_env(env_file)
|
|
19
|
+
end
|
|
20
|
+
parse_env_file(env_file)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_env_file(root_dir)
|
|
26
|
+
environment = ENV["ENVIRONMENT"]
|
|
27
|
+
if environment && !environment.empty?
|
|
28
|
+
candidate = File.join(root_dir, ".env.#{environment}")
|
|
29
|
+
return candidate if File.exist?(candidate)
|
|
30
|
+
end
|
|
31
|
+
File.join(root_dir, ".env")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_default_env(path)
|
|
35
|
+
api_key = Digest::MD5.hexdigest(Time.now.to_s)
|
|
36
|
+
content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
|
|
37
|
+
content += "\nAPI_KEY=\"#{api_key}\"\n"
|
|
38
|
+
File.write(path, content)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_env_file(path)
|
|
42
|
+
return unless File.exist?(path)
|
|
43
|
+
File.readlines(path).each do |line|
|
|
44
|
+
line = line.strip
|
|
45
|
+
next if line.empty? || line.start_with?("#")
|
|
46
|
+
if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
|
|
47
|
+
key = match[1]
|
|
48
|
+
value = match[2].gsub(/["']\z/, "")
|
|
49
|
+
ENV[key] ||= value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module FieldTypes
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def field_definitions
|
|
11
|
+
@field_definitions ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def primary_key_field
|
|
15
|
+
@primary_key_field
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def table_name(name = nil)
|
|
19
|
+
if name
|
|
20
|
+
@table_name = name
|
|
21
|
+
else
|
|
22
|
+
@table_name || self.name.split("::").last.downcase + "s"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def integer_field(name, primary_key: false, auto_increment: false, nullable: true, default: nil)
|
|
27
|
+
register_field(name, :integer, primary_key: primary_key, auto_increment: auto_increment,
|
|
28
|
+
nullable: nullable, default: default)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def string_field(name, length: 255, primary_key: false, nullable: true, default: nil)
|
|
32
|
+
register_field(name, :string, length: length, primary_key: primary_key,
|
|
33
|
+
nullable: nullable, default: default)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def text_field(name, nullable: true, default: nil)
|
|
37
|
+
register_field(name, :text, nullable: nullable, default: default)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def float_field(name, nullable: true, default: nil)
|
|
41
|
+
register_field(name, :float, nullable: nullable, default: default)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def decimal_field(name, precision: 10, scale: 2, nullable: true, default: nil)
|
|
45
|
+
register_field(name, :decimal, precision: precision, scale: scale,
|
|
46
|
+
nullable: nullable, default: default)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def boolean_field(name, nullable: true, default: nil)
|
|
50
|
+
register_field(name, :boolean, nullable: nullable, default: default)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def date_field(name, nullable: true, default: nil)
|
|
54
|
+
register_field(name, :date, nullable: nullable, default: default)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def datetime_field(name, nullable: true, default: nil)
|
|
58
|
+
register_field(name, :datetime, nullable: nullable, default: default)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def timestamp_field(name, nullable: true, default: nil)
|
|
62
|
+
register_field(name, :timestamp, nullable: nullable, default: default)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def blob_field(name, nullable: true, default: nil)
|
|
66
|
+
register_field(name, :blob, nullable: nullable, default: default)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def json_field(name, nullable: true, default: nil)
|
|
70
|
+
register_field(name, :json, nullable: nullable, default: default)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def register_field(name, type, **options)
|
|
76
|
+
field_definitions[name] = { type: type }.merge(options)
|
|
77
|
+
@primary_key_field = name if options[:primary_key]
|
|
78
|
+
|
|
79
|
+
# Define getter/setter
|
|
80
|
+
attr_accessor name
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
module Localization
|
|
6
|
+
LOCALE_DIRS = %w[locales translations i18n src/locales].freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def translations
|
|
10
|
+
@translations ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def current_locale
|
|
14
|
+
@current_locale || ENV["TINA4_LANGUAGE"] || "en"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def current_locale=(locale)
|
|
18
|
+
@current_locale = locale.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def load(root_dir = Dir.pwd)
|
|
22
|
+
LOCALE_DIRS.each do |dir|
|
|
23
|
+
locale_dir = File.join(root_dir, dir)
|
|
24
|
+
next unless Dir.exist?(locale_dir)
|
|
25
|
+
|
|
26
|
+
Dir.glob(File.join(locale_dir, "*.json")).each do |file|
|
|
27
|
+
locale = File.basename(file, ".json")
|
|
28
|
+
data = JSON.parse(File.read(file))
|
|
29
|
+
translations[locale] ||= {}
|
|
30
|
+
translations[locale].merge!(data)
|
|
31
|
+
Tina4::Debug.debug("Loaded locale: #{locale} from #{file}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Also support YAML
|
|
35
|
+
Dir.glob(File.join(locale_dir, "*.{yml,yaml}")).each do |file|
|
|
36
|
+
begin
|
|
37
|
+
require "yaml"
|
|
38
|
+
locale = File.basename(file, File.extname(file))
|
|
39
|
+
data = YAML.safe_load(File.read(file))
|
|
40
|
+
translations[locale] ||= {}
|
|
41
|
+
translations[locale].merge!(data) if data.is_a?(Hash)
|
|
42
|
+
rescue LoadError
|
|
43
|
+
Tina4::Debug.warning("YAML support requires the 'yaml' gem")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def t(key, locale: nil, default: nil, **interpolations)
|
|
50
|
+
lang = locale || current_locale
|
|
51
|
+
value = lookup(lang, key)
|
|
52
|
+
|
|
53
|
+
if value.nil? && lang != "en"
|
|
54
|
+
value = lookup("en", key)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
value = default || key if value.nil?
|
|
58
|
+
|
|
59
|
+
# Interpolation: "Hello %{name}" => "Hello World"
|
|
60
|
+
interpolations.each do |k, v|
|
|
61
|
+
value = value.gsub("%{#{k}}", v.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def add(locale, key, value)
|
|
68
|
+
translations[locale.to_s] ||= {}
|
|
69
|
+
keys = key.to_s.split(".")
|
|
70
|
+
hash = translations[locale.to_s]
|
|
71
|
+
keys[0..-2].each do |k|
|
|
72
|
+
hash[k] ||= {}
|
|
73
|
+
hash = hash[k]
|
|
74
|
+
end
|
|
75
|
+
hash[keys.last] = value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def available_locales
|
|
79
|
+
translations.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def lookup(locale, key)
|
|
85
|
+
keys = key.to_s.split(".")
|
|
86
|
+
result = translations[locale]
|
|
87
|
+
return nil unless result
|
|
88
|
+
|
|
89
|
+
keys.each do |k|
|
|
90
|
+
if result.is_a?(Hash)
|
|
91
|
+
result = result[k] || result[k.to_sym]
|
|
92
|
+
else
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
result.is_a?(String) ? result : nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
class Middleware
|
|
5
|
+
class << self
|
|
6
|
+
def before_handlers
|
|
7
|
+
@before_handlers ||= []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def after_handlers
|
|
11
|
+
@after_handlers ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def before(pattern = nil, &block)
|
|
15
|
+
before_handlers << { pattern: pattern, handler: block }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def after(pattern = nil, &block)
|
|
19
|
+
after_handlers << { pattern: pattern, handler: block }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear!
|
|
23
|
+
@before_handlers = []
|
|
24
|
+
@after_handlers = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_before(request, response)
|
|
28
|
+
before_handlers.each do |entry|
|
|
29
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
30
|
+
result = entry[:handler].call(request, response)
|
|
31
|
+
# If handler returns false, halt the request
|
|
32
|
+
return false if result == false
|
|
33
|
+
end
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_after(request, response)
|
|
38
|
+
after_handlers.each do |entry|
|
|
39
|
+
next unless matches_pattern?(request.path, entry[:pattern])
|
|
40
|
+
entry[:handler].call(request, response)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def matches_pattern?(path, pattern)
|
|
47
|
+
return true if pattern.nil?
|
|
48
|
+
case pattern
|
|
49
|
+
when String
|
|
50
|
+
path.start_with?(pattern)
|
|
51
|
+
when Regexp
|
|
52
|
+
pattern.match?(path)
|
|
53
|
+
else
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
class Migration
|
|
6
|
+
TRACKING_TABLE = "tina4_migrations"
|
|
7
|
+
|
|
8
|
+
def initialize(db, migrations_dir: nil)
|
|
9
|
+
@db = db
|
|
10
|
+
@migrations_dir = migrations_dir || File.join(Dir.pwd, "migrations")
|
|
11
|
+
ensure_tracking_table
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
pending = pending_migrations
|
|
16
|
+
if pending.empty?
|
|
17
|
+
Tina4::Debug.info("No pending migrations")
|
|
18
|
+
return []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
results = []
|
|
22
|
+
pending.each do |file|
|
|
23
|
+
result = run_migration(file)
|
|
24
|
+
results << result
|
|
25
|
+
end
|
|
26
|
+
results
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rollback(steps = 1)
|
|
30
|
+
completed = completed_migrations.last(steps)
|
|
31
|
+
completed.reverse.each do |name|
|
|
32
|
+
down_file = File.join(@migrations_dir, name.sub(".sql", ".down.sql"))
|
|
33
|
+
if File.exist?(down_file)
|
|
34
|
+
execute_sql_file(down_file)
|
|
35
|
+
remove_migration_record(name)
|
|
36
|
+
Tina4::Debug.info("Rolled back: #{name}")
|
|
37
|
+
else
|
|
38
|
+
Tina4::Debug.warning("No rollback file for: #{name}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def status
|
|
44
|
+
{
|
|
45
|
+
completed: completed_migrations,
|
|
46
|
+
pending: pending_migrations.map { |f| File.basename(f) }
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create(name)
|
|
51
|
+
FileUtils.mkdir_p(@migrations_dir)
|
|
52
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
53
|
+
filename = "#{timestamp}_#{name.gsub(/\s+/, '_')}.sql"
|
|
54
|
+
filepath = File.join(@migrations_dir, filename)
|
|
55
|
+
File.write(filepath, "-- Migration: #{name}\n-- Created: #{Time.now}\n\n")
|
|
56
|
+
|
|
57
|
+
down_filepath = filepath.sub(".sql", ".down.sql")
|
|
58
|
+
File.write(down_filepath, "-- Rollback: #{name}\n-- Created: #{Time.now}\n\n")
|
|
59
|
+
|
|
60
|
+
Tina4::Debug.info("Created migration: #{filename}")
|
|
61
|
+
filepath
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def ensure_tracking_table
|
|
67
|
+
unless @db.table_exists?(TRACKING_TABLE)
|
|
68
|
+
@db.execute(<<~SQL)
|
|
69
|
+
CREATE TABLE #{TRACKING_TABLE} (
|
|
70
|
+
id INTEGER PRIMARY KEY,
|
|
71
|
+
migration_name VARCHAR(255) NOT NULL,
|
|
72
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
73
|
+
)
|
|
74
|
+
SQL
|
|
75
|
+
Tina4::Debug.info("Created migrations tracking table")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def completed_migrations
|
|
80
|
+
result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} ORDER BY id")
|
|
81
|
+
result.map { |r| r[:migration_name] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_migrations
|
|
85
|
+
return [] unless Dir.exist?(@migrations_dir)
|
|
86
|
+
|
|
87
|
+
completed = completed_migrations
|
|
88
|
+
Dir.glob(File.join(@migrations_dir, "*.sql"))
|
|
89
|
+
.reject { |f| f.end_with?(".down.sql") }
|
|
90
|
+
.sort
|
|
91
|
+
.reject { |f| completed.include?(File.basename(f)) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_migration(file)
|
|
95
|
+
name = File.basename(file)
|
|
96
|
+
Tina4::Debug.info("Running migration: #{name}")
|
|
97
|
+
begin
|
|
98
|
+
execute_sql_file(file)
|
|
99
|
+
record_migration(name)
|
|
100
|
+
{ name: name, status: "success" }
|
|
101
|
+
rescue => e
|
|
102
|
+
Tina4::Debug.error("Migration failed: #{name} - #{e.message}")
|
|
103
|
+
{ name: name, status: "failed", error: e.message }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def execute_sql_file(file)
|
|
108
|
+
sql = File.read(file)
|
|
109
|
+
statements = sql.split(";").map(&:strip).reject(&:empty?)
|
|
110
|
+
statements.each do |stmt|
|
|
111
|
+
next if stmt.start_with?("--")
|
|
112
|
+
@db.execute(stmt)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_migration(name)
|
|
117
|
+
@db.insert(TRACKING_TABLE, { migration_name: name })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def remove_migration_record(name)
|
|
121
|
+
@db.delete(TRACKING_TABLE, { migration_name: name })
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|