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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +662 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +243 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev_reload.rb +68 -0
  14. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  15. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  16. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  17. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  18. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  19. data/lib/tina4/env.rb +55 -0
  20. data/lib/tina4/field_types.rb +84 -0
  21. data/lib/tina4/localization.rb +100 -0
  22. data/lib/tina4/middleware.rb +59 -0
  23. data/lib/tina4/migration.rb +124 -0
  24. data/lib/tina4/orm.rb +168 -0
  25. data/lib/tina4/queue.rb +117 -0
  26. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  27. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  28. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  29. data/lib/tina4/rack_app.rb +150 -0
  30. data/lib/tina4/request.rb +158 -0
  31. data/lib/tina4/response.rb +172 -0
  32. data/lib/tina4/router.rb +142 -0
  33. data/lib/tina4/scss_compiler.rb +131 -0
  34. data/lib/tina4/session.rb +145 -0
  35. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  36. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  37. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  38. data/lib/tina4/swagger.rb +123 -0
  39. data/lib/tina4/template.rb +478 -0
  40. data/lib/tina4/templates/base.twig +25 -0
  41. data/lib/tina4/templates/errors/403.twig +22 -0
  42. data/lib/tina4/templates/errors/404.twig +22 -0
  43. data/lib/tina4/templates/errors/500.twig +22 -0
  44. data/lib/tina4/testing.rb +213 -0
  45. data/lib/tina4/version.rb +5 -0
  46. data/lib/tina4/webserver.rb +101 -0
  47. data/lib/tina4/websocket.rb +167 -0
  48. data/lib/tina4/wsdl.rb +164 -0
  49. data/lib/tina4.rb +233 -0
  50. 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