diamond-orm 0.1.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.
@@ -0,0 +1,397 @@
1
+ module Diamond
2
+ module AST
3
+ class Node
4
+ # so you can `&` / `|` any two conditions together.
5
+ def &(other)
6
+ AST::And.new(self, other)
7
+ end
8
+
9
+ def |(other)
10
+ AST::Or.new(self, other)
11
+ end
12
+ end
13
+
14
+ # `table` is nil for plain `age`, or a table name for qualified
15
+ # `tags.tag` refs in blocks over joins. qualified columns render with
16
+ # their own table and ignore the eager parent-prefix.
17
+ class Column < Node
18
+ attr_reader :name, :table
19
+ def initialize(name, table: nil); @name = name; @table = table; end
20
+
21
+ def ==(other)
22
+ AST::Equality.new(self, AST::Literal.new(other))
23
+ end
24
+
25
+ def !=(other)
26
+ AST::NotEqual.new(self, AST::Literal.new(other))
27
+ end
28
+
29
+ def >(other)
30
+ AST::GreaterThan.new(self, AST::Literal.new(other))
31
+ end
32
+
33
+ def <(other)
34
+ AST::LessThan.new(self, AST::Literal.new(other))
35
+ end
36
+ end
37
+
38
+ class Literal < Node
39
+ attr_reader :value
40
+ def initialize(value); @value = value; end
41
+ end
42
+
43
+ class BinaryOp < Node
44
+ attr_reader :left, :right, :operator
45
+ def initialize(left, right, operator)
46
+ @left = left; @right = right; @operator = operator
47
+ end
48
+ end
49
+
50
+ class Equality < BinaryOp
51
+ def initialize(left, right); super(left, right, :'='); end
52
+ end
53
+
54
+ class NotEqual < BinaryOp
55
+ def initialize(left, right); super(left, right, :'<>'); end
56
+ end
57
+
58
+ # `right` may hold expressions, not just literals.
59
+ class In < Node
60
+ attr_reader :left, :right
61
+ def initialize(left, right)
62
+ @left = left
63
+ @right = right
64
+ end
65
+ end
66
+
67
+ class NotIn < Node
68
+ attr_reader :left, :right
69
+ def initialize(left, right)
70
+ @left = left
71
+ @right = right
72
+ end
73
+ end
74
+
75
+ # Wraps a QueryObject as a SQL subquery: `(SELECT ...)`.
76
+ class Subquery < Node
77
+ attr_reader :query
78
+ def initialize(query); @query = query; end
79
+ end
80
+
81
+ class GreaterThan < BinaryOp
82
+ def initialize(left, right); super(left, right, :'>'); end
83
+ end
84
+
85
+ class LessThan < BinaryOp
86
+ def initialize(left, right); super(left, right, :'<'); end
87
+ end
88
+
89
+ class GreaterEqual < BinaryOp
90
+ def initialize(left, right); super(left, right, :'>='); end
91
+ end
92
+
93
+ class LessEqual < BinaryOp
94
+ def initialize(left, right); super(left, right, :<=); end
95
+ end
96
+
97
+ # `!(cond)` and `not cond` are the same Prism shape. wraps anything;
98
+ # sqlite truthiness applies to non-boolean operands.
99
+ class Not < Node
100
+ attr_reader :condition
101
+ def initialize(condition); @condition = condition; end
102
+ end
103
+
104
+ class Like < BinaryOp
105
+ def initialize(left, right); super(left, right, :LIKE); end
106
+ end
107
+
108
+ class IsNull < Node
109
+ attr_reader :column
110
+ def initialize(column); @column = column; end
111
+ end
112
+
113
+ class IsNotNull < Node
114
+ attr_reader :column
115
+ def initialize(column); @column = column; end
116
+ end
117
+
118
+ class Between < Node
119
+ attr_reader :column, :low, :high
120
+ def initialize(column, low, high)
121
+ @column = column
122
+ @low = low
123
+ @high = high
124
+ end
125
+ end
126
+
127
+ class And < BinaryOp
128
+ def initialize(left, right); super(left, right, :AND); end
129
+ end
130
+
131
+ class Or < BinaryOp
132
+ def initialize(left, right); super(left, right, :OR); end
133
+ end
134
+
135
+ class Where < Node
136
+ attr_reader :condition
137
+ def initialize(condition); @condition = condition; end
138
+ end
139
+
140
+ class Projection < Node
141
+ attr_reader :columns
142
+ def initialize(columns); @columns = columns; end
143
+ end
144
+
145
+ # marker: SELECT DISTINCT. carries nothing; presence is the flag.
146
+ class Distinct < Node
147
+ end
148
+
149
+ # `on` maps LOCAL col to FOREIGN col, e.g. { user_id: :id }.
150
+ # `eager: true` marks the join for object-graph transform: the SQL gets
151
+ # column aliases (users.id AS users.id, posts.id AS posts.id, ...) and
152
+ # the compiler returns an Extralite::Transform spec that deduplicates
153
+ # and nests the rows.
154
+ class Join < Node
155
+ attr_reader :table_name, :type, :on, :eager, :single, :as
156
+ def initialize(table_name, type, on, eager: false, single: false, as: nil)
157
+ @table_name = table_name
158
+ @type = type
159
+ @on = on
160
+ @eager = eager
161
+ @single = single
162
+ @as = as
163
+ end
164
+ end
165
+
166
+ class With < Node
167
+ attr_reader :name, :query, :recursive
168
+ def initialize(name, query, recursive: false)
169
+ @name = name
170
+ @query = query
171
+ @recursive = recursive
172
+ end
173
+ end
174
+
175
+ # `specs` is [[col, :asc|:desc], ...]. lands after WHERE in the sql.
176
+ class Order < Node
177
+ attr_reader :specs
178
+ def initialize(specs)
179
+ @specs = specs
180
+ end
181
+ end
182
+
183
+ class Limit < Node
184
+ attr_reader :value
185
+ def initialize(value)
186
+ @value = value
187
+ end
188
+ end
189
+
190
+ class Offset < Node
191
+ attr_reader :value
192
+ def initialize(value)
193
+ @value = value
194
+ end
195
+ end
196
+
197
+ class GroupBy < Node
198
+ attr_reader :columns
199
+ def initialize(columns)
200
+ @columns = columns # Array of Symbol column names
201
+ end
202
+ end
203
+
204
+ class Having < Node
205
+ attr_reader :condition
206
+ def initialize(condition)
207
+ @condition = condition
208
+ end
209
+ end
210
+
211
+ # swap the FROM target (how you query a CTE).
212
+ class From < Node
213
+ attr_reader :name
214
+ def initialize(name); @name = name; end
215
+ end
216
+
217
+ class Function < Node
218
+ attr_reader :name, :args
219
+ def initialize(name, args)
220
+ @name = name.to_s.upcase
221
+ @args = args # Array of AST::Column or AST::Literal
222
+ end
223
+ end
224
+
225
+ class WindowFunction < Node
226
+ attr_reader :func_name, :args, :partition_by, :order_by
227
+
228
+ def initialize(func_name, args, partition_by: [], order_by: [])
229
+ @func_name = func_name.to_s.upcase
230
+ @args = args
231
+ @partition_by = partition_by
232
+ @order_by = order_by
233
+ end
234
+ end
235
+
236
+ class Union < Node
237
+ attr_reader :left, :right, :operator
238
+ def initialize(left, right, operator = "UNION ALL")
239
+ @left = left
240
+ @right = right
241
+ @operator = operator
242
+ end
243
+ end
244
+
245
+ # --- DDL Nodes ---
246
+
247
+ # column order is DDL order.
248
+ class DefineRelation < Node
249
+ attr_reader :name, :columns
250
+ def initialize(name, columns)
251
+ @name = name
252
+ @columns = columns
253
+ end
254
+ end
255
+
256
+ # `type` is a ruby class. `options` knows :primary_key, :nullable, :default.
257
+ class ColumnDefinition < Node
258
+ attr_reader :name, :type, :options
259
+ def initialize(name, type, options = {})
260
+ @name = name
261
+ @type = type
262
+ @options = options
263
+ end
264
+ end
265
+
266
+ # on_delete/on_update: :cascade, :set_null, :set_default, :restrict,
267
+ # :no_action. nil leaves the clause out.
268
+ class ForeignKey < Node
269
+ attr_reader :local_column, :ref_table, :ref_column, :on_delete, :on_update
270
+ def initialize(local_column, ref_table, ref_column = :id,
271
+ on_delete: nil, on_update: nil)
272
+ @local_column = local_column
273
+ @ref_table = ref_table
274
+ @ref_column = ref_column
275
+ @on_delete = on_delete
276
+ @on_update = on_update
277
+ end
278
+ end
279
+
280
+ # lifted into the AST so define_relation does CREATE TABLE then
281
+ # CREATE INDEX in one go.
282
+ class IndexDefinition < Node
283
+ attr_reader :name, :columns, :unique
284
+ def initialize(name, columns, unique: false)
285
+ @name = name
286
+ @columns = columns
287
+ @unique = unique
288
+ end
289
+ end
290
+
291
+ # --- DML Nodes ---
292
+
293
+ # `data`: Hash mapping column name (Symbol) to value.
294
+ class Insert < Node
295
+ attr_reader :data
296
+ def initialize(data)
297
+ @data = data
298
+ end
299
+ end
300
+
301
+ # `data`: Hash mapping column name (Symbol) to value.
302
+ class Update < Node
303
+ attr_reader :data
304
+ def initialize(data)
305
+ @data = data
306
+ end
307
+ end
308
+
309
+ # rows come from the chain's WHERE nodes.
310
+ class Delete < Node
311
+ end
312
+
313
+ # indented tree dump for staring at what a block became. one line
314
+ # per node, children indented two spaces. QueryObject#ast_tree maps
315
+ # this over the chain.
316
+ def self.dump(node, indent = 0)
317
+ pad = ' ' * indent
318
+ label = case node
319
+ when Column
320
+ node.table ? "Column(#{node.table}.#{node.name})" : "Column(#{node.name})"
321
+ when Literal
322
+ "Literal(#{node.value.inspect})"
323
+ when BinaryOp
324
+ node.class.name.split('::').last
325
+ when In, NotIn
326
+ "#{node.class.name.split('::').last}(#{node.right.size} vals)"
327
+ when IsNull, IsNotNull
328
+ node.class.name.split('::').last
329
+ when Between
330
+ 'Between'
331
+ when Not
332
+ 'Not'
333
+ when Subquery
334
+ 'Subquery'
335
+ when Function
336
+ "#{node.name}(#{node.args.size} args)"
337
+ when WindowFunction
338
+ "#{node.func_name} OVER"
339
+ when Where
340
+ 'Where'
341
+ when Projection
342
+ "Projection(#{node.columns.size} cols)"
343
+ when Join
344
+ "Join(#{node.table_name}, #{node.type}#{node.eager ? ', eager' : ''})"
345
+ when Order
346
+ labels = node.specs.map do |c, d|
347
+ name = c.is_a?(Column) && c.table ? "#{c.table}.#{c.name}" : c.to_s
348
+ "#{name} #{d}"
349
+ end
350
+ "Order(#{labels.join(', ')})"
351
+ when Limit
352
+ "Limit(#{node.value})"
353
+ when Offset
354
+ "Offset(#{node.value})"
355
+ when GroupBy
356
+ "GroupBy(#{node.columns.join(', ')})"
357
+ when Having
358
+ 'Having'
359
+ when From
360
+ "From(#{node.name})"
361
+ when With
362
+ "With(#{node.name}#{node.recursive ? ', recursive' : ''})"
363
+ when Union
364
+ "Union(#{node.operator})"
365
+ else
366
+ node.class.name.split('::').last
367
+ end
368
+ lines = ["#{pad}#{label}"]
369
+ kids = case node
370
+ when BinaryOp then [node.left, node.right]
371
+ when In, NotIn then [node.left] + (node.right.is_a?(Array) ? node.right : [node.right])
372
+ when IsNull, IsNotNull then [node.column]
373
+ when Between then [node.column, node.low, node.high]
374
+ when Not then [node.condition]
375
+ when Function then node.args
376
+ when Where, Having then [node.condition]
377
+ when Projection then node.columns
378
+ when Union then [node.left, node.right]
379
+ else []
380
+ end
381
+ kids.each do |k|
382
+ lines << (k.is_a?(Node) ? dump(k, indent + 1) : "#{pad} #{k.inspect}")
383
+ end
384
+ # With/Subquery hold whole queries — a Union node or a QueryObject
385
+ # carrying a chain. dump whichever it is.
386
+ if node.is_a?(With) || node.is_a?(Subquery)
387
+ q = node.query
388
+ if q.is_a?(Node)
389
+ lines << dump(q, indent + 1)
390
+ else
391
+ q.ast.each { |n| lines << dump(n, indent + 1) }
392
+ end
393
+ end
394
+ lines.join("\n")
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,36 @@
1
+ module Diamond
2
+ # Thin wrapper around Extralite::Changeset. Provides .apply(target_db),
3
+ # .invert, .to_blob, .load(blob) under a Diamond-y namespace.
4
+ #
5
+ # Used by Diamond.track_changes to keep callers from having to
6
+ # know that the underlying type is Extralite-specific.
7
+ class Changeset
8
+ def initialize(raw_changeset)
9
+ @raw = raw_changeset
10
+ end
11
+
12
+ # Replay the changeset on a target database (another Extralite db,
13
+ # a Diamond Engine, or your own test DB).
14
+ def apply(target)
15
+ target_db = target.is_a?(Diamond::Engine) ? target.db : target
16
+ @raw.apply(target_db)
17
+ end
18
+
19
+ # Returns a new Changeset that, when applied, undoes this one.
20
+ def invert
21
+ Diamond::Changeset.new(@raw.invert)
22
+ end
23
+
24
+ # Serialize for network transfer.
25
+ def to_blob
26
+ @raw.to_blob
27
+ end
28
+
29
+ # Build a Changeset from a serialized blob.
30
+ def self.load(blob)
31
+ cs = Extralite::Changeset.new
32
+ cs.load(blob)
33
+ Diamond::Changeset.new(cs)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'dql'
2
+ require_relative 'dml'
3
+ require_relative 'ddl'
4
+ require_relative 'registry'
5
+
6
+ module Diamond
7
+ module Compiler
8
+ module Base
9
+ # Returns [sql, params, transform_or_nil]. The transform is non-nil
10
+ # only for eager-loaded queries; non-eager callers can ignore it.
11
+ def self.compile(table, ast, params = nil)
12
+ params ||= []
13
+ DQL.compile(table, ast, params)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,94 @@
1
+ module Diamond
2
+ module Compiler
3
+ module DDL
4
+ TYPE_MAP = {
5
+ Integer => 'INTEGER',
6
+ String => 'TEXT',
7
+ Float => 'REAL',
8
+ TrueClass => 'INTEGER',
9
+ FalseClass => 'INTEGER'
10
+ }.freeze
11
+
12
+ def self.compile(node)
13
+ raise ArgumentError, "Expected AST::DefineRelation, got #{node.class}" unless node.is_a?(AST::DefineRelation)
14
+ Diamond.validate_ident!(node.name, "table name")
15
+
16
+ column_clauses = node.columns.select { |c| c.is_a?(AST::ColumnDefinition) }.map { |c| render_column(c) }
17
+ fk_clauses = node.columns.select { |c| c.is_a?(AST::ForeignKey) }.map { |c| render_foreign_key(c) }
18
+
19
+ body = (column_clauses + fk_clauses).join(', ')
20
+ sql = "CREATE TABLE IF NOT EXISTS #{node.name} (#{body})"
21
+ [sql, []]
22
+ end
23
+
24
+ def self.render_column(node)
25
+ Diamond.validate_ident!(node.name, "column name")
26
+ sql_type = TYPE_MAP[node.type] or raise ArgumentError, "Unsupported column type: #{node.type}"
27
+ parts = [node.name.to_s, sql_type]
28
+
29
+ if node.options[:primary_key]
30
+ parts << 'PRIMARY KEY'
31
+ elsif node.options[:nullable] == false
32
+ parts << 'NOT NULL'
33
+ end
34
+
35
+ if node.options.key?(:default)
36
+ parts << "DEFAULT #{render_default!(node.options[:default])}"
37
+ end
38
+
39
+ parts.join(' ')
40
+ end
41
+
42
+ # DDL cannot bind parameters, so DEFAULT literals go through a
43
+ # strict allowlist — numerics, booleans (as 1/0), and strings
44
+ # with single-quote escaping. Anything else raises instead of
45
+ # interpolating raw input into SQL.
46
+ def self.render_default!(value)
47
+ case value
48
+ when Numeric then value.to_s
49
+ when true then '1'
50
+ when false then '0'
51
+ when String then "'#{value.gsub("'", "''")}'"
52
+ when nil then 'NULL'
53
+ else raise ArgumentError, "Unsupported DEFAULT value: #{value.inspect}"
54
+ end
55
+ end
56
+
57
+ # ALTER TABLE ... ADD COLUMN for safe migrations. Only plain
58
+ # column definitions allowed — no PK/FK/index nodes (SQLite
59
+ # rejects most of those via ADD COLUMN anyway).
60
+ def self.compile_add_column(table_name, coldef)
61
+ raise ArgumentError, "Expected AST::ColumnDefinition, got #{coldef.class}" unless coldef.is_a?(AST::ColumnDefinition)
62
+ Diamond.validate_ident!(table_name, "table name")
63
+ if coldef.options[:primary_key]
64
+ raise ArgumentError, "Cannot ADD COLUMN #{coldef.name} as PRIMARY KEY (SQLite restriction)"
65
+ end
66
+ sql = "ALTER TABLE #{table_name} ADD COLUMN #{render_column(coldef)}"
67
+ [sql, []]
68
+ end
69
+
70
+ def self.render_foreign_key(node)
71
+ Diamond.validate_ident!(node.local_column, "foreign key column")
72
+ Diamond.validate_ident!(node.ref_table, "referenced table")
73
+ Diamond.validate_ident!(node.ref_column, "referenced column")
74
+ sql = "FOREIGN KEY (#{node.local_column}) REFERENCES #{node.ref_table}(#{node.ref_column})"
75
+ sql += " ON DELETE #{format_action(node.on_delete)}" if node.on_delete
76
+ sql += " ON UPDATE #{format_action(node.on_update)}" if node.on_update
77
+ sql
78
+ end
79
+
80
+ def self.format_action(sym)
81
+ sym.to_s.upcase.tr('_', ' ')
82
+ end
83
+
84
+ def self.compile_index(index_node, table_name)
85
+ Diamond.validate_ident!(index_node.name, "index name")
86
+ Diamond.validate_ident!(table_name, "table name")
87
+ index_node.columns.each { |c| Diamond.validate_ident!(c, "indexed column") }
88
+ unique_kw = index_node.unique ? 'UNIQUE ' : ''
89
+ sql = "CREATE #{unique_kw}INDEX IF NOT EXISTS #{index_node.name} ON #{table_name}(#{index_node.columns.join(', ')})"
90
+ [sql, []]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,109 @@
1
+ require_relative 'dql'
2
+
3
+ module Diamond
4
+ module Compiler
5
+ module DML
6
+ def self.compile_insert(table, insert_node, returning: [])
7
+ cols = insert_node.data.keys
8
+ placeholders = (['?'] * cols.size).join(', ')
9
+ sql = +"INSERT INTO #{table.name} (#{cols.join(', ')}) VALUES (#{placeholders})"
10
+ params = insert_node.data.values
11
+
12
+ if returning && !returning.empty?
13
+ sql << " RETURNING " << returning.map { |c| c.to_s }.join(', ')
14
+ end
15
+
16
+ db = Diamond.engine.db
17
+ stmt = db.prepare(sql)
18
+ begin
19
+ stmt.bind(*params)
20
+ if returning && !returning.empty?
21
+ # collect rows from the RETURNING clause
22
+ rows = []
23
+ stmt.each { |row| rows << row }
24
+ rows
25
+ else
26
+ stmt.to_a # forces execution
27
+ []
28
+ end
29
+ ensure
30
+ begin
31
+ stmt.close unless stmt.closed?
32
+ rescue StandardError
33
+ # ensure must not raise
34
+ end
35
+ end
36
+ returning && !returning.empty? ? rows : db.last_insert_rowid
37
+ end
38
+
39
+ def self.compile_update(table, hash, where_nodes, returning: [])
40
+ params = []
41
+ set_clause = hash.keys.map { |k| "#{k} = ?" }.join(', ')
42
+ params.concat(hash.values)
43
+
44
+ sql = +"UPDATE #{table.name} SET #{set_clause}"
45
+ unless where_nodes.empty?
46
+ conditions = where_nodes.map { |w| DQL.translate_node(w.condition, params) }
47
+ sql << " WHERE " << conditions.join(' AND ')
48
+ end
49
+ if returning && !returning.empty?
50
+ sql << " RETURNING " << returning.map { |c| c.to_s }.join(', ')
51
+ end
52
+
53
+ db = Diamond.engine.db
54
+ stmt = db.prepare(sql)
55
+ rows = nil
56
+ begin
57
+ stmt.bind(*params)
58
+ if returning && !returning.empty?
59
+ rows = []
60
+ stmt.each { |row| rows << row }
61
+ else
62
+ stmt.to_a
63
+ end
64
+ ensure
65
+ begin
66
+ stmt.close unless stmt.closed?
67
+ rescue StandardError
68
+ # ensure must not raise
69
+ end
70
+ end
71
+
72
+ returning && !returning.empty? ? rows : db.changes
73
+ end
74
+
75
+ def self.compile_delete(table, where_nodes, returning: [])
76
+ params = []
77
+ sql = +"DELETE FROM #{table.name}"
78
+ unless where_nodes.empty?
79
+ conditions = where_nodes.map { |w| DQL.translate_node(w.condition, params) }
80
+ sql << " WHERE " << conditions.join(' AND ')
81
+ end
82
+ if returning && !returning.empty?
83
+ sql << " RETURNING " << returning.map { |c| c.to_s }.join(', ')
84
+ end
85
+
86
+ db = Diamond.engine.db
87
+ stmt = db.prepare(sql)
88
+ rows = nil
89
+ begin
90
+ stmt.bind(*params)
91
+ if returning && !returning.empty?
92
+ rows = []
93
+ stmt.each { |row| rows << row }
94
+ else
95
+ stmt.to_a
96
+ end
97
+ ensure
98
+ begin
99
+ stmt.close unless stmt.closed?
100
+ rescue StandardError
101
+ # ensure must not raise
102
+ end
103
+ end
104
+
105
+ returning && !returning.empty? ? rows : db.changes
106
+ end
107
+ end
108
+ end
109
+ end