dami 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +306 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/bin/dami +10 -0
- data/docs/01.Getting_Started.md +220 -0
- data/docs/02.Models_and_Fields.md +244 -0
- data/docs/03.Querying.md +387 -0
- data/docs/04.Creating_Updating_Deleting.md +226 -0
- data/docs/05.Validation.md +259 -0
- data/docs/06.Protection.md +160 -0
- data/docs/07.Associations.md +239 -0
- data/docs/08.Scopes.md +99 -0
- data/docs/09.Migrations.md +165 -0
- data/docs/10.Flows_and_Commands.md +181 -0
- data/docs/11.Localization.md +435 -0
- data/docs/12.TheDamiWay.md +227 -0
- data/docs/Manifesto.md +305 -0
- data/docs/site.md +477 -0
- data/lib/dami/actions/command.rb +80 -0
- data/lib/dami/actions/context.rb +63 -0
- data/lib/dami/actions/draft.rb +36 -0
- data/lib/dami/actions/flow.rb +42 -0
- data/lib/dami/adapters/base.rb +40 -0
- data/lib/dami/adapters/sqlite/connection.rb +122 -0
- data/lib/dami/adapters/sqlite/core.rb +17 -0
- data/lib/dami/adapters/sqlite/query.rb +353 -0
- data/lib/dami/adapters/sqlite/schema.rb +135 -0
- data/lib/dami/cli.rb +117 -0
- data/lib/dami/configuration.rb +246 -0
- data/lib/dami/core.rb +98 -0
- data/lib/dami/dsl_guardrails.rb +45 -0
- data/lib/dami/errors.rb +89 -0
- data/lib/dami/inflector.rb +245 -0
- data/lib/dami/localization.rb +131 -0
- data/lib/dami/migration.rb +84 -0
- data/lib/dami/migrator.rb +105 -0
- data/lib/dami/plugins/associations.rb +284 -0
- data/lib/dami/plugins/nested_attributes.rb +180 -0
- data/lib/dami/plugins/protection.rb +30 -0
- data/lib/dami/plugins/validations.rb +90 -0
- data/lib/dami/query/builder.rb +132 -0
- data/lib/dami/query/enumerable.rb +62 -0
- data/lib/dami/query/persistence.rb +133 -0
- data/lib/dami/record_proxy.rb +32 -0
- data/lib/dami/result.rb +27 -0
- data/lib/dami/schema/diff.rb +84 -0
- data/lib/dami/schema/dumper.rb +60 -0
- data/lib/dami/schema/generator.rb +69 -0
- data/lib/dami/schema/introspector.rb +34 -0
- data/lib/dami/schema/loader.rb +58 -0
- data/lib/dami/schema.rb +13 -0
- data/lib/dami/validation_rules.rb +72 -0
- data/lib/dami/version.rb +3 -0
- data/lib/dami.rb +32 -0
- metadata +218 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Dami
|
|
3
|
+
module Adapters
|
|
4
|
+
class Base
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
@connection = nil
|
|
8
|
+
end
|
|
9
|
+
def [](model_name)
|
|
10
|
+
Dami.db(model_name)
|
|
11
|
+
end
|
|
12
|
+
def ensure_schema_migrations_table
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def table_exists?(table_name)
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
def connect; raise NotImplementedError; end
|
|
20
|
+
def add_index(table, column, options = {}); raise NotImplementedError; end
|
|
21
|
+
def remove_index(table, column, options = {}); raise NotImplementedError; end
|
|
22
|
+
def tables; raise NotImplementedError; end
|
|
23
|
+
def columns(table_name); raise NotImplementedError; end
|
|
24
|
+
def indexes(table_name); raise NotImplementedError; end
|
|
25
|
+
def execute(sql, params = []); raise NotImplementedError; end
|
|
26
|
+
def get_first_row(sql, params = []); raise NotImplementedError; end
|
|
27
|
+
def last_insert_row_id; raise NotImplementedError; end
|
|
28
|
+
def find_record(model_name, id); raise NotImplementedError; end
|
|
29
|
+
def query_records(query); raise NotImplementedError; end
|
|
30
|
+
def count_records(query); raise NotImplementedError; end
|
|
31
|
+
def insert_record(model_name, data); raise NotImplementedError; end
|
|
32
|
+
def insert_many(model_name, records); raise NotImplementedError; end
|
|
33
|
+
def update_records(query, data); raise NotImplementedError; end
|
|
34
|
+
def delete_records(query); raise NotImplementedError; end
|
|
35
|
+
def transaction(&block); raise NotImplementedError; end
|
|
36
|
+
def fetch_association(record, association_name); raise NotImplementedError; end
|
|
37
|
+
def preload_associations(proxies, relations); raise NotImplementedError; end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# File: lib/dami/adapters/sqlite/connection.rb
|
|
2
|
+
require 'connection_pool'
|
|
3
|
+
module SqliteConnection
|
|
4
|
+
def connect
|
|
5
|
+
path = @config[:path].to_s
|
|
6
|
+
pool_size = @config[:pool_size] || 5
|
|
7
|
+
# An in-memory database exists per connection, so a pool of N connections
|
|
8
|
+
# would be N unrelated databases. Force a single connection for ':memory:'.
|
|
9
|
+
pool_size = 1 if path == ':memory:'
|
|
10
|
+
@connection_pool ||= ConnectionPool.new(size: pool_size, timeout: 5) do
|
|
11
|
+
SQLite3::Database.new(path).tap do |db|
|
|
12
|
+
db.results_as_hash = true
|
|
13
|
+
db.busy_timeout = 5000
|
|
14
|
+
db.execute("PRAGMA journal_mode = WAL;") unless path == ':memory:'
|
|
15
|
+
db.execute("PRAGMA synchronous = NORMAL;")
|
|
16
|
+
db.execute("PRAGMA foreign_keys = ON;")
|
|
17
|
+
db.instance_variable_set(:@statement_cache, {})
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Runs the block in a transaction on one pooled connection.
|
|
24
|
+
# Nested calls on the same thread become SAVEPOINTs, so an inner failure
|
|
25
|
+
# rolls back only its own writes and an outer failure rolls back everything.
|
|
26
|
+
def transaction(&block)
|
|
27
|
+
@connection_pool.with do |conn|
|
|
28
|
+
if conn.transaction_active?
|
|
29
|
+
depth = (Thread.current[:dami_savepoint_depth] || 0) + 1
|
|
30
|
+
Thread.current[:dami_savepoint_depth] = depth
|
|
31
|
+
savepoint = "dami_sp_#{depth}"
|
|
32
|
+
conn.execute("SAVEPOINT #{savepoint}")
|
|
33
|
+
begin
|
|
34
|
+
result = yield
|
|
35
|
+
conn.execute("RELEASE SAVEPOINT #{savepoint}")
|
|
36
|
+
result
|
|
37
|
+
rescue ::Exception
|
|
38
|
+
conn.execute("ROLLBACK TO SAVEPOINT #{savepoint}")
|
|
39
|
+
conn.execute("RELEASE SAVEPOINT #{savepoint}")
|
|
40
|
+
raise
|
|
41
|
+
ensure
|
|
42
|
+
Thread.current[:dami_savepoint_depth] = depth - 1
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
conn.transaction do
|
|
46
|
+
begin
|
|
47
|
+
Thread.current[:dami_sqlite_connection] = conn
|
|
48
|
+
yield
|
|
49
|
+
ensure
|
|
50
|
+
Thread.current[:dami_sqlite_connection] = nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
rescue SQLite3::Exception => e
|
|
56
|
+
handle_sqlite_error(e)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def execute(sql, params = [])
|
|
60
|
+
params = convert_params(params)
|
|
61
|
+
conn = Thread.current[:dami_sqlite_connection]
|
|
62
|
+
conn ? conn.execute(sql, params) : @connection_pool.with { |c| c.execute(sql, params) }
|
|
63
|
+
rescue SQLite3::Exception => e
|
|
64
|
+
handle_sqlite_error(e, sql: sql)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def get_first_row(sql, params = [])
|
|
68
|
+
params = convert_params(params)
|
|
69
|
+
conn = Thread.current[:dami_sqlite_connection]
|
|
70
|
+
conn ? conn.get_first_row(sql, params) : @connection_pool.with { |c| c.get_first_row(sql, params) }
|
|
71
|
+
rescue SQLite3::Exception => e
|
|
72
|
+
handle_sqlite_error(e, sql: sql)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def last_insert_row_id
|
|
76
|
+
conn = Thread.current[:dami_sqlite_connection]
|
|
77
|
+
conn ? conn.last_insert_row_id : @connection_pool.with(&:last_insert_row_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# One place that turns Ruby values into what SQLite can bind. Used by every
|
|
83
|
+
# code path that talks to the driver (raw execute, prepared statements, inserts).
|
|
84
|
+
def convert_params(params)
|
|
85
|
+
Array(params).map { |value| convert_value(value) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def convert_value(value)
|
|
89
|
+
case value
|
|
90
|
+
when Time then value.utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
91
|
+
when DateTime then value.to_time.utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
92
|
+
when Date then value.to_s
|
|
93
|
+
when TrueClass, FalseClass then value ? 1 : 0
|
|
94
|
+
when Hash, Array then JSON.generate(value)
|
|
95
|
+
else value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_sqlite_error(error, sql: nil)
|
|
100
|
+
msg = error.message
|
|
101
|
+
if msg.include?('UNIQUE constraint failed')
|
|
102
|
+
column = extract_column_from_unique_error(msg)
|
|
103
|
+
raise Dami::UniqueConstraintViolation.new(msg, column: column)
|
|
104
|
+
elsif msg.include?('FOREIGN KEY constraint failed')
|
|
105
|
+
raise Dami::ForeignKeyViolation.new(msg)
|
|
106
|
+
elsif msg.include?('NOT NULL constraint failed')
|
|
107
|
+
raise Dami::NotNullViolation.new(msg, column: extract_column_from_not_null_error(msg))
|
|
108
|
+
else
|
|
109
|
+
raise error
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def extract_column_from_unique_error(msg)
|
|
114
|
+
match = msg.match(/UNIQUE constraint failed: (\w+)\.(\w+)/)
|
|
115
|
+
match ? match[2].to_sym : nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_column_from_not_null_error(msg)
|
|
119
|
+
match = msg.match(/NOT NULL constraint failed: (\w+)\.(\w+)/)
|
|
120
|
+
match ? match[2].to_sym : nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'sqlite3'
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../base'
|
|
5
|
+
require_relative 'connection'
|
|
6
|
+
require_relative 'query'
|
|
7
|
+
require_relative 'schema'
|
|
8
|
+
module Dami
|
|
9
|
+
module Adapters
|
|
10
|
+
class Sqlite < Base
|
|
11
|
+
include SqliteConnection
|
|
12
|
+
include SqliteQuery
|
|
13
|
+
include SqliteSchema
|
|
14
|
+
include Dami::Plugins::Associations::AdapterMethods
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'time'
|
|
3
|
+
module SqliteQuery
|
|
4
|
+
OPERATOR_MAP = { gt: '>', lt: '<', gte: '>=', lte: '<=', not: '!='}.freeze
|
|
5
|
+
|
|
6
|
+
def find_record(model_name, id)
|
|
7
|
+
sql = "SELECT * FROM #{model_name} WHERE id = ? LIMIT 1"
|
|
8
|
+
rows = execute_prepared(sql, [id])
|
|
9
|
+
rows.empty? ? nil : convert_row(rows.first, model_name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def query_records(query)
|
|
13
|
+
sql, params = build_query_sql(query)
|
|
14
|
+
rows = execute_prepared(sql, params)
|
|
15
|
+
rows.map { |row| convert_row(row, query[:model_name], query[:select_columns]) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def count_records(query)
|
|
19
|
+
# Build a query that only selects the count
|
|
20
|
+
count_query = query.merge(
|
|
21
|
+
select_columns: ['COUNT(*) as count'],
|
|
22
|
+
order_by: nil, # Ordering is irrelevant for a count
|
|
23
|
+
limit: nil,
|
|
24
|
+
offset: nil
|
|
25
|
+
)
|
|
26
|
+
sql, params = build_query_sql(count_query)
|
|
27
|
+
result = get_first_row(sql, params)
|
|
28
|
+
# The result will be a hash like {'count' => 123}, so we extract the value.
|
|
29
|
+
result ? result['count'] : 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def query_exists?(query)
|
|
33
|
+
query_with_limit = query.merge(select_columns: ['1'], limit: 1)
|
|
34
|
+
sql, params = build_query_sql(query_with_limit)
|
|
35
|
+
!execute_prepared(sql, params).empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def insert_record(model_name, data)
|
|
39
|
+
prepared_data = prepare_data(stamp_timestamps(data, model_name, :create), model_name)
|
|
40
|
+
columns = prepared_data.keys.join(', ')
|
|
41
|
+
placeholders = (['?'] * prepared_data.keys.size).join(', ')
|
|
42
|
+
sql = "INSERT INTO #{model_name} (#{columns}) VALUES (#{placeholders})"
|
|
43
|
+
transaction do
|
|
44
|
+
execute_prepared(sql, prepared_data.values)
|
|
45
|
+
find_record(model_name, last_insert_row_id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
def insert_many(table, records)
|
|
49
|
+
return [] if records.empty?
|
|
50
|
+
records = records.map { |r| prepare_data(stamp_timestamps(r, table, :create), table) }
|
|
51
|
+
columns = records.first.keys
|
|
52
|
+
columns.each { |c| validate_identifier(c) }
|
|
53
|
+
placeholders = "(#{columns.map { '?' }.join(', ')})"
|
|
54
|
+
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES #{records.map { placeholders }.join(', ')}"
|
|
55
|
+
values = records.flat_map { |record| columns.map { |col| record[col] } }
|
|
56
|
+
# Both statements must run on the same connection, or last_insert_row_id
|
|
57
|
+
# can come from a different pooled connection. transaction guarantees that.
|
|
58
|
+
transaction do
|
|
59
|
+
execute(sql, values)
|
|
60
|
+
last_id = last_insert_row_id
|
|
61
|
+
count = records.size
|
|
62
|
+
(last_id - count + 1..last_id).to_a
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update_records(query, data)
|
|
67
|
+
prepared_data = prepare_data(stamp_timestamps(data, query[:model_name], :update), query[:model_name])
|
|
68
|
+
set_clause = prepared_data.keys.map { |k| "#{k} = ?" }.join(', ')
|
|
69
|
+
|
|
70
|
+
full_query = {
|
|
71
|
+
model_name: query[:model_name],
|
|
72
|
+
conditions: query[:conditions] || [],
|
|
73
|
+
order_by: query[:order_by],
|
|
74
|
+
limit: query[:limit],
|
|
75
|
+
offset: query[:offset]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
sql, params = build_query_sql(full_query, "UPDATE #{query[:model_name]} SET #{set_clause}")
|
|
79
|
+
execute_prepared(sql, prepared_data.values + params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def delete_records(query)
|
|
83
|
+
full_query = {
|
|
84
|
+
model_name: query[:model_name],
|
|
85
|
+
conditions: query[:conditions] || [],
|
|
86
|
+
order_by: query[:order_by],
|
|
87
|
+
limit: query[:limit],
|
|
88
|
+
offset: query[:offset]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
sql, params = build_query_sql(full_query, "DELETE FROM #{query[:model_name]}")
|
|
92
|
+
execute_prepared(sql, params)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def execute_prepared(sql, params = [])
|
|
98
|
+
converted_params = convert_params(params)
|
|
99
|
+
conn = Thread.current[:dami_sqlite_connection]
|
|
100
|
+
return run_prepared(conn, sql, converted_params) if conn
|
|
101
|
+
@connection_pool.with { |c| run_prepared(c, sql, converted_params) }
|
|
102
|
+
rescue SQLite3::Exception => e
|
|
103
|
+
handle_sqlite_error(e, sql: sql)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def run_prepared(conn, sql, converted_params)
|
|
107
|
+
begin
|
|
108
|
+
cache = conn.instance_variable_get(:@statement_cache)
|
|
109
|
+
stmt = cache[sql] ||= conn.prepare(sql)
|
|
110
|
+
stmt.reset!
|
|
111
|
+
|
|
112
|
+
results = stmt.execute(converted_params)
|
|
113
|
+
|
|
114
|
+
if results.respond_to?(:to_a)
|
|
115
|
+
results.to_a.map do |row|
|
|
116
|
+
if row.is_a?(Hash)
|
|
117
|
+
row
|
|
118
|
+
else
|
|
119
|
+
Hash[stmt.columns.zip(row)]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
[]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_query_sql(query, base_sql = nil)
|
|
129
|
+
if base_sql
|
|
130
|
+
sql_parts = [base_sql]
|
|
131
|
+
else
|
|
132
|
+
select_clause = if query[:select_columns] && !query[:select_columns].empty?
|
|
133
|
+
columns = query[:select_columns].map(&:to_s).join(', ')
|
|
134
|
+
"SELECT #{columns} FROM #{query[:model_name]}"
|
|
135
|
+
else
|
|
136
|
+
"SELECT * FROM #{query[:model_name]}"
|
|
137
|
+
end
|
|
138
|
+
sql_parts = [select_clause]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
params = []
|
|
142
|
+
|
|
143
|
+
if !base_sql && query[:joins] && !query[:joins].empty?
|
|
144
|
+
query[:joins].each do |join|
|
|
145
|
+
join_table = validate_identifier(join[:table])
|
|
146
|
+
join_conditions = join[:conditions]
|
|
147
|
+
join_conditions.each { |l, r| validate_identifier(l); validate_identifier(r) }
|
|
148
|
+
|
|
149
|
+
join_type_sql = case join[:type]
|
|
150
|
+
when :left then "LEFT OUTER JOIN"
|
|
151
|
+
else "INNER JOIN"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
join_clause = "#{join_type_sql} #{join_table} ON "
|
|
155
|
+
conditions = []
|
|
156
|
+
|
|
157
|
+
join_conditions.each do |left, right|
|
|
158
|
+
conditions << "#{query[:model_name]}.#{left} = #{join_table}.#{right}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
join_clause << conditions.join(' AND ')
|
|
162
|
+
sql_parts << join_clause
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unless query[:conditions].empty?
|
|
167
|
+
where_clauses, where_params = build_where_clause(query[:conditions])
|
|
168
|
+
sql_parts << "WHERE #{where_clauses}" unless where_clauses.empty?
|
|
169
|
+
params.concat(where_params)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if !base_sql && query[:order_by]
|
|
173
|
+
sql_parts << "ORDER BY #{build_order_clause(query[:order_by])}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
sql_parts << "LIMIT #{query[:limit]}" if !base_sql && query[:limit]
|
|
177
|
+
sql_parts << "OFFSET #{query[:offset]}" if !base_sql && query[:offset]
|
|
178
|
+
|
|
179
|
+
[sql_parts.join(' '), params]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_where_clause(conditions)
|
|
183
|
+
clauses = []
|
|
184
|
+
params = []
|
|
185
|
+
|
|
186
|
+
conditions.each_with_index do |(type, cond_part), index|
|
|
187
|
+
join_word = (index > 0) ? type.to_s.upcase : ""
|
|
188
|
+
|
|
189
|
+
if cond_part.is_a?(Array) && cond_part.first.is_a?(String) && cond_part.first.include?('?')
|
|
190
|
+
# This is a raw SQL fragment like ['age > ?', 30]
|
|
191
|
+
clauses << "#{join_word} #{cond_part.first}".strip
|
|
192
|
+
params.concat(cond_part[1..-1])
|
|
193
|
+
elsif cond_part.is_a?(Array)
|
|
194
|
+
# This is a nested block of conditions
|
|
195
|
+
sub_clause, sub_params = build_where_clause(cond_part)
|
|
196
|
+
clauses << "#{join_word} (#{sub_clause})".strip unless sub_clause.empty?
|
|
197
|
+
params.concat(sub_params)
|
|
198
|
+
elsif cond_part.is_a?(Hash)
|
|
199
|
+
# This is a hash of conditions
|
|
200
|
+
hash_clauses = []
|
|
201
|
+
hash_params = []
|
|
202
|
+
cond_part.each do |field, value|
|
|
203
|
+
clause, values = build_condition_part(field, value)
|
|
204
|
+
hash_clauses << clause
|
|
205
|
+
hash_params.concat(values)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
unless hash_clauses.empty?
|
|
209
|
+
full_clause = "(#{hash_clauses.join(' AND ')})"
|
|
210
|
+
clauses << "#{join_word} #{full_clause}".strip
|
|
211
|
+
params.concat(hash_params)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
[clauses.join(' '), params]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def build_condition_part(field, value)
|
|
220
|
+
validate_identifier(field)
|
|
221
|
+
case value
|
|
222
|
+
when nil then ["#{field} IS NULL", []]
|
|
223
|
+
when Hash
|
|
224
|
+
sub_clauses = []
|
|
225
|
+
params = []
|
|
226
|
+
value.each do |op, val|
|
|
227
|
+
# FIX #2: Handle `{ not: nil }` to generate `IS NOT NULL`
|
|
228
|
+
if op == :not && val.nil?
|
|
229
|
+
sub_clauses << "#{field} IS NOT NULL"
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
case op
|
|
234
|
+
when :not_in
|
|
235
|
+
# FIX #1: Handle `{ not_in: [] }` to generate `1=1` (true)
|
|
236
|
+
return ["1=1", []] if val.empty?
|
|
237
|
+
placeholders = (['?'] * val.size).join(',')
|
|
238
|
+
sub_clauses << "#{field} NOT IN (#{placeholders})"
|
|
239
|
+
params.concat(val)
|
|
240
|
+
when :starts_with
|
|
241
|
+
sub_clauses << "#{field} LIKE ?"
|
|
242
|
+
params << "#{val}%"
|
|
243
|
+
when :contains
|
|
244
|
+
sub_clauses << "#{field} LIKE ?"
|
|
245
|
+
params << "%#{val}%"
|
|
246
|
+
when :ends_with
|
|
247
|
+
sub_clauses << "#{field} LIKE ?"
|
|
248
|
+
params << "%#{val}"
|
|
249
|
+
else
|
|
250
|
+
sql_op = OPERATOR_MAP[op]
|
|
251
|
+
raise "Unknown operator: #{op}" unless sql_op
|
|
252
|
+
sub_clauses << "#{field} #{sql_op} ?"
|
|
253
|
+
params << val
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
["(#{sub_clauses.join(' AND ')})", params]
|
|
257
|
+
when Array
|
|
258
|
+
return ["1=0", []] if value.empty?
|
|
259
|
+
placeholders = (['?'] * value.size).join(',')
|
|
260
|
+
["#{field} IN (#{placeholders})", value]
|
|
261
|
+
else ["#{field} = ?", [value]]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def build_order_clause(order_config)
|
|
266
|
+
return nil unless order_config
|
|
267
|
+
|
|
268
|
+
parts = case order_config
|
|
269
|
+
when Hash
|
|
270
|
+
order_config.map do |field, dir|
|
|
271
|
+
validate_order_fragment(field)
|
|
272
|
+
dir_sql = dir.to_s.upcase
|
|
273
|
+
raise Dami::InvalidCommand, "Invalid order direction: #{dir}" unless %w[ASC DESC].include?(dir_sql)
|
|
274
|
+
"#{field} #{dir_sql}"
|
|
275
|
+
end
|
|
276
|
+
when String, Symbol
|
|
277
|
+
order_config.to_s.split(',').map do |part|
|
|
278
|
+
field, dir = part.strip.split(/\s+/)
|
|
279
|
+
validate_order_fragment(field)
|
|
280
|
+
dir_sql = (dir || 'ASC').upcase
|
|
281
|
+
raise Dami::InvalidCommand, "Invalid order direction: #{dir}" unless %w[ASC DESC].include?(dir_sql)
|
|
282
|
+
"#{field} #{dir_sql}"
|
|
283
|
+
end
|
|
284
|
+
else
|
|
285
|
+
raise Dami::InvalidCommand, "Invalid order argument: #{order_config.inspect}"
|
|
286
|
+
end
|
|
287
|
+
parts.join(', ')
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def validate_order_fragment(fragment)
|
|
291
|
+
raise Dami::InvalidCommand, "Invalid character in ORDER BY clause: #{fragment}" unless fragment.to_s.match?(/\A[\w\.]+\z/)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def prepare_data(data, model_name)
|
|
295
|
+
model_config = Dami.find_model(model_name) rescue nil
|
|
296
|
+
data.each_with_object({}) do |(key, value), out|
|
|
297
|
+
validate_identifier(key)
|
|
298
|
+
field_type = model_config&.dig(:fields, key.to_sym, :type)
|
|
299
|
+
out[key] = if field_type == :json
|
|
300
|
+
value.nil? ? nil : JSON.generate(value)
|
|
301
|
+
else
|
|
302
|
+
convert_value(value)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Fills created_at / updated_at when the model declares them and the caller
|
|
308
|
+
# did not supply a value. Operation is :create or :update.
|
|
309
|
+
def stamp_timestamps(data, model_name, operation)
|
|
310
|
+
model_config = Dami.find_model(model_name) rescue nil
|
|
311
|
+
return data unless model_config
|
|
312
|
+
fields = model_config[:fields] || {}
|
|
313
|
+
now = Time.now.utc
|
|
314
|
+
data = data.dup
|
|
315
|
+
if operation == :create && fields.key?(:created_at) && !data.key?(:created_at)
|
|
316
|
+
data[:created_at] = now
|
|
317
|
+
end
|
|
318
|
+
if fields.key?(:updated_at) && !data.key?(:updated_at)
|
|
319
|
+
data[:updated_at] = now
|
|
320
|
+
end
|
|
321
|
+
data
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Column and table names are interpolated into SQL, so they must be plain
|
|
325
|
+
# identifiers. Values are always bound; this guards the names.
|
|
326
|
+
def validate_identifier(name)
|
|
327
|
+
str = name.to_s
|
|
328
|
+
return str if str.match?(/\A[A-Za-z_][\w.]*\z/)
|
|
329
|
+
raise Dami::InvalidIdentifier, "Invalid SQL identifier: #{str.inspect}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def convert_row(row, model_name, select_columns = nil)
|
|
333
|
+
return {} unless row.is_a?(Hash)
|
|
334
|
+
model_config = Dami.find_model(model_name) rescue nil
|
|
335
|
+
converted_row = row.each_with_object({}) do |(key, value), hash|
|
|
336
|
+
key = key.to_sym
|
|
337
|
+
field_type = model_config&.dig(:fields, key, :type)
|
|
338
|
+
hash[key] = case field_type
|
|
339
|
+
when :boolean then value.nil? ? nil : (value == 1 || value == true)
|
|
340
|
+
when :json then value.nil? ? nil : (JSON.parse(value) rescue value)
|
|
341
|
+
else value
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
if select_columns && !select_columns.empty?
|
|
345
|
+
selected_keys = select_columns.map do |col|
|
|
346
|
+
col_str = col.to_s
|
|
347
|
+
(col_str.include?(' AS ') ? col_str.split(' AS ').last.strip : (col_str.include?('.') ? col_str.split('.').last : col_str)).to_sym
|
|
348
|
+
end
|
|
349
|
+
converted_row.select! { |key, _| selected_keys.include?(key) }
|
|
350
|
+
end
|
|
351
|
+
converted_row
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SqliteSchema
|
|
3
|
+
# In lib/dami/adapters/sqlite/schema.rb or connection.rb
|
|
4
|
+
def ensure_schema_migrations_table
|
|
5
|
+
# Check if table exists
|
|
6
|
+
table_check = execute("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
|
|
7
|
+
return unless table_check.empty?
|
|
8
|
+
|
|
9
|
+
# Create the table
|
|
10
|
+
execute <<-SQL
|
|
11
|
+
CREATE TABLE schema_migrations (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
version VARCHAR(255) NOT NULL UNIQUE
|
|
14
|
+
)
|
|
15
|
+
SQL
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def table_exists?(table_name)
|
|
19
|
+
!execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [table_name.to_s]).empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute_schema_operations(ops)
|
|
23
|
+
ops.each do |op|
|
|
24
|
+
case op[:type]
|
|
25
|
+
when :create_table then create_table(op[:name], op[:columns])
|
|
26
|
+
when :drop_table then drop_table(op[:name])
|
|
27
|
+
when :add_column then add_column(op[:table], op[:name], op[:column_type], **op[:options])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
def create_table(name, columns)
|
|
32
|
+
column_defs = columns.map { |c| column_definition(c) }.join(", ")
|
|
33
|
+
execute("CREATE TABLE #{name} (#{column_defs})")
|
|
34
|
+
end
|
|
35
|
+
def drop_table(name)
|
|
36
|
+
execute("DROP TABLE IF EXISTS #{name}")
|
|
37
|
+
end
|
|
38
|
+
def add_column(table, name, type, **options)
|
|
39
|
+
execute("ALTER TABLE #{table} ADD COLUMN #{column_definition({name: name, type: type, **options})}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def column_exists?(table, column)
|
|
43
|
+
return false unless table_exists?(table)
|
|
44
|
+
columns(table).any? { |col| col[:name] == column.to_s }
|
|
45
|
+
end
|
|
46
|
+
# options: unique: true
|
|
47
|
+
def add_index(table, column, options = {})
|
|
48
|
+
columns = Array(column)
|
|
49
|
+
index_name = "index_#{table}_on_#{columns.join('_and_')}"
|
|
50
|
+
unique = options[:unique] ? 'UNIQUE ' : ''
|
|
51
|
+
execute("CREATE #{unique}INDEX #{index_name} ON #{table} (#{columns.join(', ')})")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def remove_index(table, column, options = {})
|
|
55
|
+
index_name = "index_#{table}_on_#{Array(column).join('_and_')}"
|
|
56
|
+
execute("DROP INDEX IF EXISTS #{index_name}")
|
|
57
|
+
end
|
|
58
|
+
def tables
|
|
59
|
+
# Query the sqlite_master table for all user-defined table names.
|
|
60
|
+
execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
|
|
61
|
+
.map { |row| row["name"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def columns(table_name)
|
|
65
|
+
# Use PRAGMA to get column info and map it to a friendly format.
|
|
66
|
+
execute("PRAGMA table_info(#{table_name})").map do |col|
|
|
67
|
+
{ name: col['name'], type: sqlite_to_dami_type(col['type']) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def indexes(table_name)
|
|
72
|
+
# Use PRAGMA to get index info.
|
|
73
|
+
# We filter out primary key and implicit indexes.
|
|
74
|
+
execute("PRAGMA index_list('#{table_name}')")
|
|
75
|
+
.select { |index| index['origin'] == 'c' } # 'c' means created by a CREATE INDEX statement
|
|
76
|
+
.map do |index|
|
|
77
|
+
# For each index, find out which column it's on.
|
|
78
|
+
index_info = execute("PRAGMA index_info('#{index['name']}')").first
|
|
79
|
+
{ table: table_name, column: index_info['name'] } if index_info
|
|
80
|
+
end
|
|
81
|
+
.compact
|
|
82
|
+
end
|
|
83
|
+
# Column options understood here (all optional):
|
|
84
|
+
# null: false -> NOT NULL
|
|
85
|
+
# default: value -> DEFAULT <literal> (:current_timestamp for CURRENT_TIMESTAMP)
|
|
86
|
+
# unique: true -> UNIQUE
|
|
87
|
+
# references: :table -> REFERENCES table(id) (add `on_delete: :cascade` if wanted)
|
|
88
|
+
def column_definition(col)
|
|
89
|
+
parts = ["#{col[:name]} #{map_type(col[:type])}"]
|
|
90
|
+
parts << "PRIMARY KEY AUTOINCREMENT" if col[:type] == :primary_key
|
|
91
|
+
parts << "NOT NULL" if col[:null] == false
|
|
92
|
+
parts << "UNIQUE" if col[:unique]
|
|
93
|
+
parts << "DEFAULT #{sql_literal(col[:default])}" if col.key?(:default)
|
|
94
|
+
if col[:references]
|
|
95
|
+
parts << "REFERENCES #{col[:references]}(#{col[:references_column] || 'id'})"
|
|
96
|
+
parts << "ON DELETE #{col[:on_delete].to_s.upcase.tr('_', ' ')}" if col[:on_delete]
|
|
97
|
+
end
|
|
98
|
+
parts.join(' ')
|
|
99
|
+
end
|
|
100
|
+
# One type map for create_table AND add_column (Migration delegates here).
|
|
101
|
+
def map_type(type)
|
|
102
|
+
case type.to_sym
|
|
103
|
+
when :primary_key, :integer, :boolean then 'INTEGER'
|
|
104
|
+
when :float, :decimal then 'REAL'
|
|
105
|
+
when :datetime, :date, :time then 'DATETIME'
|
|
106
|
+
when :string, :text, :json then 'TEXT'
|
|
107
|
+
else 'TEXT'
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
def sql_literal(value)
|
|
111
|
+
case value
|
|
112
|
+
when nil then 'NULL'
|
|
113
|
+
when :current_timestamp then 'CURRENT_TIMESTAMP'
|
|
114
|
+
when Numeric then value.to_s
|
|
115
|
+
when TrueClass, FalseClass then value ? '1' : '0'
|
|
116
|
+
else "'#{value.to_s.gsub("'", "''")}'"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# New private helper to map SQLite types back to Dami's DSL types.
|
|
124
|
+
def sqlite_to_dami_type(db_type)
|
|
125
|
+
case db_type.upcase
|
|
126
|
+
when /INT/ then :integer
|
|
127
|
+
when /CHAR|TEXT/ then :string
|
|
128
|
+
when /REAL|DOUBLE|FLOAT/ then :float
|
|
129
|
+
when /DECIMAL/ then :decimal
|
|
130
|
+
when /BOOL/ then :boolean
|
|
131
|
+
when /DATETIME/ then :datetime
|
|
132
|
+
else :string # Default fallback
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|