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,262 @@
1
+ require_relative 'compiler/dql'
2
+ require 'json'
3
+
4
+ module Diamond
5
+ # JSON writer mixed into every Diamond struct class. Serializes
6
+ # straight from positional values — `{"id":1,"name":"x"}` — without
7
+ # the intermediate Hash that `to_h` allocates. Makes
8
+ # `JSON.generate(array_of_structs)` correct (stdlib has no Struct
9
+ # support and renders `"#<struct …>"` garbage) and fast.
10
+ module StructJSON
11
+ def to_json(*)
12
+ out = +'{'
13
+ first = true
14
+ each_pair do |member, value|
15
+ out << ',' unless first
16
+ first = false
17
+ out << '"' << member.to_s << '":'
18
+ out << JSON.generate(value)
19
+ end
20
+ out << '}'
21
+ out
22
+ end
23
+ end
24
+
25
+ module StructFactory
26
+ # struct classes pile up per projection shape. cached per-Ractor on
27
+ # Ractor.current's local storage so each worker warms its own cache
28
+ # without tripping isolation errors.
29
+ CACHES_KEY = Diamond::RACTOR_KEYS[:struct_caches]
30
+
31
+ def self.caches
32
+ Ractor.current[CACHES_KEY] ||= Hash.new { |h, k| h[k] = {} }
33
+ end
34
+
35
+ def self.cache
36
+ caches[:structs]
37
+ end
38
+
39
+ def self.clear_caches!
40
+ Ractor.current[CACHES_KEY] = Hash.new { |h, k| h[k] = {} }
41
+ end
42
+
43
+ def self.create(table, row_hash, projection_nodes = nil)
44
+ members = resolve_members(projection_nodes, table)
45
+
46
+ member_names = members.map(&:first)
47
+ klass = struct_class_for(table, member_names, projection_nodes)
48
+
49
+ values = members.map { |member, sql_name| row_hash[sql_name] }
50
+ klass.new(*values).freeze
51
+ end
52
+
53
+ # Build a frozen struct from a RETURNING result row (one of the
54
+ # columns the caller asked for, in the order asked). Reuses the
55
+ # existing struct cache keyed by member-name list.
56
+ def self.create_from_returning(table, row, columns)
57
+ member_names = columns.map(&:to_sym)
58
+ cache_key = "#{table.name}\0#{member_names.join("\0")}"
59
+ klass = cache[cache_key] ||= Struct.new(*member_names) do
60
+ include Diamond::StructJSON
61
+ def save; raise Diamond::InertObjectError, "Data is inert! Use the Table proxy to update."; end
62
+ end
63
+ values = columns.map { |c| row[c.to_sym] }
64
+ klass.new(*values).freeze
65
+ end
66
+
67
+ # Build a frozen struct from an Extralite::Transform result row. The
68
+ # row shape is a Hash keyed by the transform spec's keys; for parent
69
+ # columns the key is the bare column name (e.g. "id"), for eagerly-
70
+ # loaded children the key is the table name and the value is an Array
71
+ # of nested Hashes (each becoming a child struct).
72
+ #
73
+ # Post-processes the Extralite output:
74
+ # - strips the "table." prefix from nested column names
75
+ # - drops null sentinel rows LEFT JOIN leaves for parents without kids
76
+ # - deduplicates children by primary key (Extralite only dedupes
77
+ # within a single parent row's join group, not across the join)
78
+ def self.create_eager(parent_table, row)
79
+ members = []
80
+ table_sym = parent_table.is_a?(Symbol) ? parent_table : parent_table.name
81
+
82
+ row.each do |key, value|
83
+ if value.is_a?(Array)
84
+ # collection (to-many eager join)
85
+ member_name = key.to_sym
86
+ # extract actual child table name from column aliases (table.col)
87
+ sample = value.first
88
+ child_table_sym = if sample.is_a?(Hash) && sample.keys.first.to_s.include?('.')
89
+ sample.keys.first.to_s.split('.', 2).first.to_sym
90
+ else
91
+ member_name
92
+ end
93
+ child_schema = Diamond.engine.schema_cache[child_table_sym]
94
+ child_pk = child_schema[:primary_key]
95
+
96
+ # strip table. prefix from each child row's keys
97
+ stripped = value.map do |child_row|
98
+ child_row.each_with_object({}) do |(k, v), h|
99
+ short = k.to_s.sub(/^#{Regexp.escape(child_table_sym.to_s)}\./, '').to_sym
100
+ h[short] = v
101
+ end
102
+ end
103
+
104
+ # drop null sentinels (Extralite leaves these for parents without
105
+ # children after a LEFT JOIN)
106
+ stripped.reject! { |child_row| child_row.values.all?(&:nil?) }
107
+
108
+ # dedupe by primary key
109
+ if child_pk
110
+ seen = {}
111
+ stripped = stripped.reject do |r|
112
+ pk_val = r[child_pk]
113
+ if seen[pk_val]
114
+ true
115
+ else
116
+ seen[pk_val] = true
117
+ false
118
+ end
119
+ end
120
+ end
121
+
122
+ child_structs = stripped.map { |child_row| create_eager(child_table_sym, child_row) }
123
+ members << [member_name, child_structs]
124
+ elsif value.is_a?(Hash)
125
+ # single object (to-one eager join)
126
+ member_name = key.to_sym
127
+ # the key in the transform output is the member name (may be aliased).
128
+ # the actual child table name is encoded in the column aliases (table.col).
129
+ # extract it from any key to find the schema for stripping prefixes.
130
+ sample_key = value.keys.first
131
+ child_table_sym = if sample_key.to_s.include?('.')
132
+ sample_key.to_s.split('.', 2).first.to_sym
133
+ else
134
+ # no dotted key — fall back to member name as table name
135
+ member_name
136
+ end
137
+
138
+ # strip table. prefix from keys
139
+ stripped = value.each_with_object({}) do |(k, v), h|
140
+ short = k.to_s.sub(/^#{Regexp.escape(child_table_sym.to_s)}\./, '').to_sym
141
+ h[short] = v
142
+ end
143
+
144
+ # drop null sentinel (parent without child) -> nil
145
+ if stripped.values.all?(&:nil?)
146
+ members << [member_name, nil]
147
+ else
148
+ child_struct = create_eager(child_table_sym, stripped)
149
+ members << [member_name, child_struct]
150
+ end
151
+ else
152
+ members << [key.to_sym, value]
153
+ end
154
+ end
155
+
156
+ member_names = members.map(&:first)
157
+ cache_key = "#{table_sym}\0#{member_names.join("\0")}"
158
+ klass = cache[cache_key] ||= Struct.new(*member_names) do
159
+ include Diamond::StructJSON
160
+ def save; raise Diamond::InertObjectError, "Data is inert! Use the Table proxy to update."; end
161
+ end
162
+ klass.new(*members.map(&:last)).freeze
163
+ end
164
+
165
+ # array rows land positionally, no hash lookups. select order ==
166
+ # member order, `*` included. takes the first members.size values:
167
+ # joined `SELECT *` yields trailing columns of other tables that
168
+ # the hash path ignores by key — slicing keeps both paths identical.
169
+ def self.create_from_array(table, row_array, projection_nodes = nil)
170
+ build(factory_for(table, projection_nodes), row_array)
171
+ end
172
+
173
+ # Bulk path for materialize: resolves members + struct class ONCE
174
+ # per query instead of per row (the dominant cost in the hot loop
175
+ # per bench/rails/breakdown.rb: ~59% of collection time).
176
+ def self.create_many(table, rows, projection_nodes = nil)
177
+ factory = factory_for(table, projection_nodes)
178
+ rows.map { |row| build(factory, row) }
179
+ end
180
+
181
+ # Pre-resolved [klass, member_count] for streaming consumers
182
+ # (Cursor) that cannot collect rows up front.
183
+ def self.factory_for(table, projection_nodes = nil)
184
+ members = resolve_members(projection_nodes, table)
185
+ member_names = members.map(&:first)
186
+ [struct_class_for(table, member_names, projection_nodes), member_names.size]
187
+ end
188
+
189
+ def self.build(factory, row_array)
190
+ klass, count = factory
191
+ klass.new(*(row_array.size == count ? row_array : row_array.first(count))).freeze
192
+ end
193
+
194
+ def self.struct_class_for(table, member_names, projection_nodes)
195
+ # NUL can't appear in identifiers, so [a_b, c] and [a, b_c] stop colliding.
196
+ cache_key = projection_nodes ? "#{table.name}\0#{member_names.join("\0")}" : table.name.to_s
197
+
198
+ unless cache[cache_key]
199
+ cache[cache_key] = Struct.new(*member_names) do
200
+ include Diamond::StructJSON
201
+ def save; raise Diamond::InertObjectError, "Data is inert! Use the Table proxy to update."; end
202
+ end
203
+ end
204
+
205
+ cache[cache_key]
206
+ end
207
+
208
+ def self.resolve_members(projection_nodes, table)
209
+ if projection_nodes.nil? || projection_nodes.empty?
210
+ return table.schema[:columns].map { |c| [c, c] }
211
+ end
212
+
213
+ projection_nodes.map do |node|
214
+ sql_name = compile_node(node)
215
+ member_name = member_name_for(node)
216
+ [member_name, sql_name]
217
+ end
218
+ end
219
+
220
+ def self.compile_node(node)
221
+ # Extralite returns symbol keys for both columns and function results
222
+ # (e.g. { :id => 1, :"COUNT(id)" => 4 }). We mirror that here so
223
+ # `row_hash[sql_name]` hits the right key.
224
+ case node
225
+ when Diamond::AST::Column
226
+ node.name.to_sym
227
+ when Diamond::AST::Function
228
+ Diamond::Compiler::DQL.translate_node(node, []).to_sym
229
+ when Diamond::AST::WindowFunction
230
+ Diamond::Compiler::DQL.translate_node(node, []).to_sym
231
+ else
232
+ Diamond::Compiler::DQL.translate_node(node, []).to_sym
233
+ end
234
+ end
235
+
236
+ def self.member_name_for(node)
237
+ case node
238
+ when Diamond::AST::Column
239
+ node.name
240
+ when Diamond::AST::Function
241
+ first_arg = node.args.first
242
+ suffix = case first_arg
243
+ when Diamond::AST::Column then first_arg.name
244
+ when Diamond::AST::Literal
245
+ val = first_arg.value
246
+ val.is_a?(String) ? val : (val.nil? ? 'nil' : val.to_s)
247
+ else
248
+ 'all'
249
+ end
250
+ :"#{node.name.downcase}_#{suffix}"
251
+ when Diamond::AST::WindowFunction
252
+ parts = []
253
+ parts.concat(node.partition_by) unless node.partition_by.empty?
254
+ parts.concat(node.order_by) unless node.order_by.empty?
255
+ suffix = parts.empty? ? 'all' : parts.join('_')
256
+ :"#{node.func_name.downcase}_#{suffix}"
257
+ else
258
+ raise "Unknown projection node: #{node.class}"
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,32 @@
1
+ require_relative 'ast'
2
+ require_relative 'query_object'
3
+
4
+ module Diamond
5
+ class Table
6
+ include Enumerable
7
+
8
+ attr_reader :name, :schema
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ @schema = Diamond.engine.schema_cache[name]
13
+ end
14
+
15
+ # column names in DDL order.
16
+ def columns
17
+ @schema[:columns]
18
+ end
19
+
20
+ # primary key column, or nil.
21
+ def primary_key
22
+ @schema[:primary_key]
23
+ end
24
+
25
+ # Sequel-style sugar: Users[1] → find(1).first. Returns the frozen
26
+ # struct, or nil when no row matches (Hash-like miss semantics —
27
+ # use find! when you want RecordNotFound instead).
28
+ def [](id)
29
+ find(id).first
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module Diamond
2
+ VERSION = "0.1.0"
3
+ end