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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +306 -0
  3. data/LICENSE +21 -0
  4. data/README.md +106 -0
  5. data/bin/dami +10 -0
  6. data/docs/01.Getting_Started.md +220 -0
  7. data/docs/02.Models_and_Fields.md +244 -0
  8. data/docs/03.Querying.md +387 -0
  9. data/docs/04.Creating_Updating_Deleting.md +226 -0
  10. data/docs/05.Validation.md +259 -0
  11. data/docs/06.Protection.md +160 -0
  12. data/docs/07.Associations.md +239 -0
  13. data/docs/08.Scopes.md +99 -0
  14. data/docs/09.Migrations.md +165 -0
  15. data/docs/10.Flows_and_Commands.md +181 -0
  16. data/docs/11.Localization.md +435 -0
  17. data/docs/12.TheDamiWay.md +227 -0
  18. data/docs/Manifesto.md +305 -0
  19. data/docs/site.md +477 -0
  20. data/lib/dami/actions/command.rb +80 -0
  21. data/lib/dami/actions/context.rb +63 -0
  22. data/lib/dami/actions/draft.rb +36 -0
  23. data/lib/dami/actions/flow.rb +42 -0
  24. data/lib/dami/adapters/base.rb +40 -0
  25. data/lib/dami/adapters/sqlite/connection.rb +122 -0
  26. data/lib/dami/adapters/sqlite/core.rb +17 -0
  27. data/lib/dami/adapters/sqlite/query.rb +353 -0
  28. data/lib/dami/adapters/sqlite/schema.rb +135 -0
  29. data/lib/dami/cli.rb +117 -0
  30. data/lib/dami/configuration.rb +246 -0
  31. data/lib/dami/core.rb +98 -0
  32. data/lib/dami/dsl_guardrails.rb +45 -0
  33. data/lib/dami/errors.rb +89 -0
  34. data/lib/dami/inflector.rb +245 -0
  35. data/lib/dami/localization.rb +131 -0
  36. data/lib/dami/migration.rb +84 -0
  37. data/lib/dami/migrator.rb +105 -0
  38. data/lib/dami/plugins/associations.rb +284 -0
  39. data/lib/dami/plugins/nested_attributes.rb +180 -0
  40. data/lib/dami/plugins/protection.rb +30 -0
  41. data/lib/dami/plugins/validations.rb +90 -0
  42. data/lib/dami/query/builder.rb +132 -0
  43. data/lib/dami/query/enumerable.rb +62 -0
  44. data/lib/dami/query/persistence.rb +133 -0
  45. data/lib/dami/record_proxy.rb +32 -0
  46. data/lib/dami/result.rb +27 -0
  47. data/lib/dami/schema/diff.rb +84 -0
  48. data/lib/dami/schema/dumper.rb +60 -0
  49. data/lib/dami/schema/generator.rb +69 -0
  50. data/lib/dami/schema/introspector.rb +34 -0
  51. data/lib/dami/schema/loader.rb +58 -0
  52. data/lib/dami/schema.rb +13 -0
  53. data/lib/dami/validation_rules.rb +72 -0
  54. data/lib/dami/version.rb +3 -0
  55. data/lib/dami.rb +32 -0
  56. 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