structured_data_to_sql 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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +9 -0
  3. data/bin/structured-data-to-sql +6 -0
  4. data/lib/structured_data_to_sql/cli.rb +405 -0
  5. data/lib/structured_data_to_sql/conversion_result.rb +94 -0
  6. data/lib/structured_data_to_sql/diagnostic.rb +82 -0
  7. data/lib/structured_data_to_sql/diagnostics_report.rb +187 -0
  8. data/lib/structured_data_to_sql/errors.rb +48 -0
  9. data/lib/structured_data_to_sql/format.rb +41 -0
  10. data/lib/structured_data_to_sql/io_support.rb +154 -0
  11. data/lib/structured_data_to_sql/json/exporter_manifest.rb +127 -0
  12. data/lib/structured_data_to_sql/json/json_schema_loader.rb +310 -0
  13. data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +1960 -0
  14. data/lib/structured_data_to_sql/json/profiles.rb +14 -0
  15. data/lib/structured_data_to_sql/json/record_streamer.rb +477 -0
  16. data/lib/structured_data_to_sql/json/schema_inferrer.rb +150 -0
  17. data/lib/structured_data_to_sql/json/shredder.rb +241 -0
  18. data/lib/structured_data_to_sql/json/sql_emitter.rb +198 -0
  19. data/lib/structured_data_to_sql/json_converter.rb +913 -0
  20. data/lib/structured_data_to_sql/mysql_dump_xml/invalid_character_report.rb +82 -0
  21. data/lib/structured_data_to_sql/mysql_dump_xml/sanitizer.rb +152 -0
  22. data/lib/structured_data_to_sql/mysql_dump_xml/sax_parser.rb +111 -0
  23. data/lib/structured_data_to_sql/mysql_dump_xml/sql_emitter.rb +104 -0
  24. data/lib/structured_data_to_sql/mysql_dump_xml/table_data_filter.rb +343 -0
  25. data/lib/structured_data_to_sql/mysql_dump_xml/table_discovery.rb +651 -0
  26. data/lib/structured_data_to_sql/mysql_dump_xml/table_structure.rb +98 -0
  27. data/lib/structured_data_to_sql/mysql_dump_xml_converter.rb +4 -0
  28. data/lib/structured_data_to_sql/options.rb +89 -0
  29. data/lib/structured_data_to_sql/progress_reporter.rb +348 -0
  30. data/lib/structured_data_to_sql/sql_text.rb +29 -0
  31. data/lib/structured_data_to_sql/version.rb +5 -0
  32. data/lib/structured_data_to_sql/xml_converter.rb +651 -0
  33. data/lib/structured_data_to_sql/xml_dump_converter.rb +4 -0
  34. data/lib/structured_data_to_sql.rb +54 -0
  35. metadata +120 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "profiles/khoros_api_export"
4
+
5
+ module StructuredDataToSql
6
+ module Json
7
+ # Preparation profiles selectable through the converter's +profile:+
8
+ # option (CLI --profile). A profile class provides NAME, REQUIRED_SCHEMA,
9
+ # an InputValidator, .write_preamble(out), and #write(out).
10
+ module Profiles
11
+ REGISTRY = { KhorosApiExport::NAME => KhorosApiExport }.freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj"
4
+
5
+ module StructuredDataToSql
6
+ module Json
7
+ # Streams the records out of one JSON document without materializing the
8
+ # whole document: only the envelope (everything outside the records array)
9
+ # and one record at a time are held in memory.
10
+ #
11
+ # Records-array detection, first match wins:
12
+ # 1. the array at the end of the explicit dot-notation records_path
13
+ # (e.g. "data.community.roles.edges"); error if absent
14
+ # 2. the array under the top-level "data" key
15
+ # 3. the sole top-level array value (buffered into the envelope first,
16
+ # so only suitable for modest non-"data" files; use records_path for
17
+ # huge ones)
18
+ # 4. none: the whole document is a single record
19
+ #
20
+ # NDJSON (newline-delimited JSON) support:
21
+ # When ndjson: true is passed, or auto-detection determines the stream
22
+ # contains multiple top-level JSON documents (one per line), each line is
23
+ # parsed independently and records from all lines are yielded in order.
24
+ # All lines must share the same structure (same records_path). The
25
+ # envelope is taken from the first non-empty line; subsequent lines'
26
+ # envelopes are ignored (they are structurally identical page wrappers).
27
+ class RecordStreamer
28
+ Result =
29
+ Struct.new(
30
+ :envelope,
31
+ :records_key,
32
+ :record_count,
33
+ :truncated,
34
+ :parse_error,
35
+ :skipped_lines,
36
+ keyword_init: true
37
+ )
38
+
39
+ # Maximum bytes read for each line considered during NDJSON probing.
40
+ NDJSON_PROBE_LINE_BYTES = 16 * 1024 * 1024
41
+
42
+ def initialize(records_path: nil)
43
+ @records_path = records_path
44
+ end
45
+
46
+ # Yields each record (a plain Hash/Array/scalar tree) with its 0-based
47
+ # ordinal. Returns a Result with the envelope hash (records excluded).
48
+ #
49
+ # With recover: true, a parse failure inside the records array returns a
50
+ # truncated Result covering the records that completed before the
51
+ # failure (each yielded record is a fully parsed tree, so everything
52
+ # already yielded is intact); the partial tail record is discarded.
53
+ # Failures before the first complete record still raise.
54
+ #
55
+ # With ndjson: true, each line of +io+ is treated as a separate JSON
56
+ # document. Auto-detection (ndjson: :auto, the default) probes the first
57
+ # two non-empty lines; set ndjson: false to disable auto-detection.
58
+ def each_record(io, recover: false, ndjson: :auto, &block)
59
+ if ndjson == true || (ndjson == :auto && ndjson_stream?(io))
60
+ each_record_ndjson(io, recover: recover, &block)
61
+ else
62
+ each_record_single(io, recover: recover, &block)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Single-document path (original behaviour)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def each_record_single(io, recover: false, &block)
73
+ path_keys, records_key = path_keys_and_root_key
74
+ handler =
75
+ Handler.new(
76
+ records_key: records_key,
77
+ records_path: path_keys,
78
+ on_record: block
79
+ )
80
+ begin
81
+ Oj.sc_parse(handler, io)
82
+ rescue Oj::ParseError, EncodingError => e
83
+ unless recover && handler.records_found &&
84
+ handler.record_count.positive?
85
+ raise
86
+ end
87
+
88
+ return(
89
+ Result.new(
90
+ envelope: handler.envelope || {},
91
+ records_key: handler.records_key_used,
92
+ record_count: handler.record_count,
93
+ truncated: true,
94
+ parse_error: e.message.sub(/ \[\S+\]\z/, "")
95
+ )
96
+ )
97
+ end
98
+ root = handler.root_value
99
+ if handler.records_found
100
+ return(
101
+ Result.new(
102
+ envelope: handler.envelope || {},
103
+ records_key: handler.records_key_used,
104
+ record_count: handler.record_count
105
+ )
106
+ )
107
+ end
108
+
109
+ if @records_path
110
+ raise UsageError,
111
+ "No array found under records path '#{@records_path}' in JSON input"
112
+ end
113
+
114
+ fallback_records(root, handler, &block)
115
+ end
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # NDJSON path — one JSON document per line
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def each_record_ndjson(io, recover: false, &block)
122
+ total_count = 0
123
+ envelope = nil
124
+ records_key_used = nil
125
+ truncated = false
126
+ parse_error = nil
127
+ skipped_lines = 0
128
+ line_number = 0
129
+
130
+ io.each_line do |line|
131
+ line_number += 1
132
+ next if line.empty?
133
+ next if line.getbyte(0) <= 32 && line.match?(/\A[[:space:]]*\z/)
134
+
135
+ begin
136
+ root = Oj.strict_load(line)
137
+ rescue Oj::ParseError, EncodingError => e
138
+ if recover
139
+ truncated = true
140
+ skipped_lines += 1
141
+ parse_error = e.message.sub(/ \[\S+\]\z/, "")
142
+ next
143
+ end
144
+ raise
145
+ end
146
+ page_envelope, page_records_key, records =
147
+ ndjson_page(root, line_number)
148
+ records.each do |record|
149
+ block.call(record, total_count)
150
+ total_count += 1
151
+ end
152
+ envelope ||= page_envelope
153
+ records_key_used ||= page_records_key
154
+ end
155
+
156
+ if recover && skipped_lines.positive? && total_count.zero?
157
+ raise UsageError,
158
+ "No complete records could be recovered from NDJSON input (skipped #{skipped_lines} malformed line(s))"
159
+ end
160
+
161
+ Result.new(
162
+ envelope: envelope || {},
163
+ records_key: records_key_used,
164
+ record_count: total_count,
165
+ truncated: truncated || nil,
166
+ parse_error: parse_error,
167
+ skipped_lines: skipped_lines.positive? ? skipped_lines : nil
168
+ )
169
+ end
170
+
171
+ # NDJSON lines are complete JSON documents, so Oj's direct strict parser
172
+ # is substantially cheaper than constructing a streaming handler and IO
173
+ # wrapper for every line. The resulting page is already bounded by one
174
+ # line; select its records with the same rules as the streaming path.
175
+ def ndjson_page(root, line_number)
176
+ if @records_path
177
+ path = @records_path.split(".")
178
+ parent = root
179
+ path[0...-1].each { |key| parent = parent[key] if parent.is_a?(Hash) }
180
+ records = parent.delete(path.last) if parent.is_a?(Hash)
181
+ unless records.is_a?(Array)
182
+ raise UsageError,
183
+ "No array found under records path '#{@records_path}' in JSON input on NDJSON line #{line_number}"
184
+ end
185
+ return root, @records_path, records
186
+ end
187
+
188
+ return {}, nil, root if root.is_a?(Array)
189
+ unless root.is_a?(Hash)
190
+ raise UsageError,
191
+ "JSON input is a bare scalar value and cannot be converted"
192
+ end
193
+
194
+ return root, "data", root.delete("data") if root["data"].is_a?(Array)
195
+
196
+ array_keys = root.keys.select { |key| root[key].is_a?(Array) }
197
+ if array_keys.length == 1
198
+ key = array_keys.first
199
+ return root, key, root.delete(key)
200
+ end
201
+
202
+ [{}, nil, [root]]
203
+ end
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # NDJSON auto-detection
207
+ # ---------------------------------------------------------------------------
208
+
209
+ # Returns true if +io+ looks like a newline-delimited multi-document
210
+ # stream. The probe reads up to two non-empty lines and checks whether the
211
+ # first is a complete JSON object/array. The IO is rewound to its original
212
+ # position after probing, so the caller continues from where it started.
213
+ #
214
+ # This works for seekable IO objects. CountingIO wraps readpartial/read but
215
+ # does not expose seek; we reach through to the underlying IO when
216
+ # available, otherwise we fall back to false (no auto-detection). Forced
217
+ # ndjson: true does not require seeking.
218
+ def ndjson_stream?(io)
219
+ # Unwrap CountingIO so we can seek.
220
+ raw_io = io.respond_to?(:__raw_io__) ? io.__raw_io__ : io
221
+ unless raw_io.respond_to?(:seek) && raw_io.respond_to?(:gets) &&
222
+ raw_io.respond_to?(:pos)
223
+ return false
224
+ end
225
+
226
+ original_pos = raw_io.pos
227
+ raw_io.seek(original_pos)
228
+ lines = []
229
+ while lines.length < 2
230
+ line = raw_io.gets(NDJSON_PROBE_LINE_BYTES)
231
+ break if line.nil?
232
+
233
+ lines << line unless line.strip.empty?
234
+ end
235
+ return false if lines.length < 2
236
+
237
+ first_line = lines.first
238
+
239
+ # The first line must be a syntactically complete JSON object/array.
240
+ begin
241
+ first_value = Oj.load(first_line)
242
+ rescue Oj::ParseError, EncodingError
243
+ return false
244
+ end
245
+ return false unless first_value.is_a?(Hash) || first_value.is_a?(Array)
246
+
247
+ # There is more content after that first line.
248
+ true
249
+ rescue IOError, SystemCallError
250
+ false
251
+ ensure
252
+ begin
253
+ raw_io.seek(original_pos) if raw_io && original_pos
254
+ rescue IOError, SystemCallError
255
+ nil
256
+ end
257
+ end
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Shared helpers
261
+ # ---------------------------------------------------------------------------
262
+
263
+ def path_keys_and_root_key
264
+ path_keys = @records_path ? @records_path.split(".") : nil
265
+ # Single-key path: legacy behaviour — treat it as the flat records key
266
+ # at depth 1 (same as before this change, so old callers are unaffected).
267
+ records_key =
268
+ (path_keys && path_keys.length == 1) ? path_keys.first : "data"
269
+ [path_keys, records_key]
270
+ end
271
+
272
+ def fallback_records(root, handler, &block)
273
+ unless root.is_a?(Hash)
274
+ raise UsageError,
275
+ "JSON input is a bare scalar value and cannot be converted"
276
+ end
277
+
278
+ envelope = handler.envelope
279
+
280
+ array_keys = envelope.keys.select { |key| envelope[key].is_a?(Array) }
281
+ if array_keys.length == 1
282
+ key = array_keys.first
283
+ records = envelope.delete(key)
284
+ records.each_with_index(&block)
285
+ Result.new(
286
+ envelope: envelope,
287
+ records_key: key,
288
+ record_count: records.length
289
+ )
290
+ else
291
+ block.call(envelope, 0)
292
+ Result.new(envelope: {}, records_key: nil, record_count: 1)
293
+ end
294
+ end
295
+
296
+ # -- Oj::ScHandler callback names
297
+ class Handler < ::Oj::ScHandler
298
+ RECORDS = Object.new
299
+
300
+ attr_reader :envelope, :records_key_used, :record_count, :root_value
301
+
302
+ # +records_key+ is the single-key fallback used to match a depth-1 array
303
+ # (kept for legacy single-key paths and the default "data" case).
304
+ # +records_path+ is the full key sequence for multi-level paths; when set,
305
+ # the handler descends through intermediate objects until the final segment
306
+ # matches an array — that array becomes the RECORDS stream.
307
+ def initialize(records_key:, on_record:, records_path: nil)
308
+ super()
309
+ @records_key = records_key
310
+ @records_path = records_path
311
+ @on_record = on_record
312
+ @stack = []
313
+ @pending_key = nil
314
+ @envelope = nil
315
+ @records_found = false
316
+ @records_key_used = nil
317
+ @record_count = 0
318
+ @root_value = nil
319
+ # Number of path segments matched by the current object branch.
320
+ @path_matched = 0
321
+ @path_match_stack = []
322
+ # The most recent key seen at the current path match depth.
323
+ @path_pending_key = nil
324
+ end
325
+
326
+ def records_found
327
+ @records_found
328
+ end
329
+
330
+ def hash_start
331
+ hash = {}
332
+ previous_path_matched = @path_matched
333
+ @path_matched += 1 if path_intermediate_value?
334
+ @path_match_stack.push(previous_path_matched)
335
+ @envelope = hash if @stack.empty?
336
+ @stack.push(hash)
337
+ hash
338
+ end
339
+
340
+ def hash_end
341
+ obj = @stack.pop
342
+ @path_matched = @path_match_stack.pop || 0
343
+ obj
344
+ end
345
+
346
+ def hash_key(key)
347
+ # Legacy single-level match: track the pending key at depth 1.
348
+ @pending_key = key if @stack.length == 1
349
+
350
+ # Multi-level path: track the key seen at the current match depth,
351
+ # and advance the match counter for non-final segments.
352
+ if @records_path && !@records_found
353
+ @path_pending_key = key if @stack.length == @path_matched + 1
354
+ end
355
+
356
+ key
357
+ end
358
+
359
+ def hash_set(hash, key, value)
360
+ hash[key] = value unless value.equal?(RECORDS)
361
+ end
362
+
363
+ def array_start
364
+ target =
365
+ if @stack.empty?
366
+ @records_found = true
367
+ RECORDS
368
+ elsif multi_level_records_array?
369
+ @records_found = true
370
+ @records_key_used = @records_path.join(".")
371
+ RECORDS
372
+ elsif @stack.length == 1 && @stack.first.equal?(@envelope) &&
373
+ @pending_key == @records_key
374
+ @records_found = true
375
+ @records_key_used = @records_key
376
+ RECORDS
377
+ else
378
+ []
379
+ end
380
+ @stack.push(target)
381
+ target
382
+ end
383
+
384
+ def array_end
385
+ @stack.pop
386
+ end
387
+
388
+ def array_append(array, value)
389
+ if array.equal?(RECORDS)
390
+ @on_record.call(value, @record_count)
391
+ @record_count += 1
392
+ else
393
+ array << value
394
+ end
395
+ end
396
+
397
+ def add_value(value)
398
+ @root_value = value
399
+ @envelope = {} if @envelope.nil?
400
+ end
401
+
402
+ private
403
+
404
+ # A key matching an intermediate path segment only counts after its
405
+ # value actually starts as an object. This keeps partial matches from
406
+ # leaking into sibling branches or across scalar/array values.
407
+ def path_intermediate_value?
408
+ return false unless @records_path && !@records_found
409
+ return false if @stack.empty?
410
+ return false if @path_matched >= @records_path.length - 1
411
+
412
+ @stack.length == @path_matched + 1 &&
413
+ @path_pending_key == @records_path[@path_matched]
414
+ end
415
+
416
+ # Returns true when the multi-level path has been fully matched and the
417
+ # current pending_key is the final segment — meaning this array_start
418
+ # call is the records array.
419
+ def multi_level_records_array?
420
+ return false unless @records_path && !@records_found
421
+
422
+ # All path segments except the last have been matched via hash keys;
423
+ # the current @path_pending_key is the final segment.
424
+ @path_matched == @records_path.length - 1 &&
425
+ @path_pending_key == @records_path.last &&
426
+ @stack.length == @path_matched + 1
427
+ end
428
+ end
429
+ # rubocop:enable Naming/MethodName
430
+ end
431
+
432
+ # IO wrapper reporting consumed bytes; Oj.sc_parse drives custom IO-like
433
+ # objects through readpartial.
434
+ class CountingIO
435
+ def initialize(io, &on_bytes)
436
+ @io = io
437
+ @on_bytes = on_bytes
438
+ end
439
+
440
+ # Exposed so RecordStreamer can reach the underlying IO for seek-based
441
+ # NDJSON probe/rewind without going through the counting wrapper.
442
+ def __raw_io__
443
+ @io
444
+ end
445
+
446
+ def readpartial(max_length, out_buffer = nil)
447
+ chunk =
448
+ (
449
+ if out_buffer
450
+ @io.readpartial(max_length, out_buffer)
451
+ else
452
+ @io.readpartial(max_length)
453
+ end
454
+ )
455
+ @on_bytes.call(chunk.bytesize) if chunk
456
+ chunk
457
+ end
458
+
459
+ def read(length = nil, out_buffer = nil)
460
+ chunk = out_buffer ? @io.read(length, out_buffer) : @io.read(length)
461
+ @on_bytes.call(chunk.bytesize) if chunk
462
+ chunk
463
+ end
464
+
465
+ # Delegates each_line to the underlying IO, reporting bytes as each line
466
+ # is yielded so progress tracking still works during NDJSON iteration.
467
+ def each_line(&block)
468
+ return enum_for(:each_line) unless block
469
+
470
+ @io.each_line do |line|
471
+ @on_bytes.call(line.bytesize)
472
+ block.call(line)
473
+ end
474
+ end
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module StructuredDataToSql
6
+ module Json
7
+ ColumnDef =
8
+ Struct.new(:name, :kind, :sql_type, :null, :comment, keyword_init: true)
9
+
10
+ # Accumulates per-column type observations across all records of a table
11
+ # and finalizes them into MySQL column definitions via a widening lattice:
12
+ # unknown -> boolean | integer -> float | datetime -> string (sized into
13
+ # VARCHAR/TEXT/MEDIUMTEXT/LONGTEXT by max byte length).
14
+ class ColumnProfile
15
+ ISO_DATETIME =
16
+ /\A\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:?\d{2})?\z/
17
+
18
+ attr_reader :kind, :seen_count
19
+
20
+ def initialize(raw_dates: false)
21
+ @raw_dates = raw_dates
22
+ @kind = :unknown
23
+ @max_bytes = 0
24
+ @null_seen = false
25
+ @seen_count = 0
26
+ end
27
+
28
+ def observe(value)
29
+ @seen_count += 1
30
+ case value
31
+ when nil
32
+ @null_seen = true
33
+ when JsonValue
34
+ @kind = :json
35
+ @max_bytes = [@max_bytes, value.text.bytesize].max
36
+ when true, false
37
+ widen(:boolean)
38
+ when Integer
39
+ widen(:integer)
40
+ when Float
41
+ widen(:float)
42
+ when String
43
+ @max_bytes = [@max_bytes, value.bytesize].max
44
+ widen(!@raw_dates && value.match?(ISO_DATETIME) ? :datetime : :string)
45
+ else
46
+ @max_bytes = [@max_bytes, value.to_s.bytesize].max
47
+ widen(:string)
48
+ end
49
+ end
50
+
51
+ def finalize(name, row_count)
52
+ ColumnDef.new(
53
+ name: name,
54
+ kind: @kind,
55
+ sql_type: sql_type,
56
+ null: @null_seen || @seen_count < row_count
57
+ )
58
+ end
59
+
60
+ private
61
+
62
+ def widen(observed)
63
+ return @kind = :json if @kind == :json || observed == :json
64
+ return @kind = observed if @kind == :unknown
65
+ return if @kind == observed
66
+
67
+ numeric = [@kind, observed].sort == %i[float integer]
68
+ @kind = numeric ? :float : :string
69
+ end
70
+
71
+ def sql_type
72
+ case @kind
73
+ when :boolean
74
+ "TINYINT(1)"
75
+ when :integer
76
+ "BIGINT"
77
+ when :float
78
+ "DOUBLE"
79
+ when :datetime
80
+ "DATETIME"
81
+ when :json
82
+ "LONGTEXT"
83
+ else
84
+ string_type
85
+ end
86
+ end
87
+
88
+ def string_type
89
+ if @max_bytes <= 255
90
+ "VARCHAR(255)"
91
+ elsif @max_bytes <= 65_535
92
+ "TEXT"
93
+ elsif @max_bytes <= 16_777_215
94
+ "MEDIUMTEXT"
95
+ else
96
+ "LONGTEXT"
97
+ end
98
+ end
99
+ end
100
+
101
+ # Column profiles for one table, in first-observation order.
102
+ class TableProfile
103
+ attr_reader :table_rel, :row_count
104
+
105
+ def initialize(table_rel, raw_dates: false)
106
+ @table_rel = table_rel
107
+ @raw_dates = raw_dates
108
+ @columns = {}
109
+ @row_count = 0
110
+ end
111
+
112
+ def observe_row(columns)
113
+ @row_count += 1
114
+ columns.each do |name, value|
115
+ (@columns[name] ||= ColumnProfile.new(raw_dates: @raw_dates)).observe(
116
+ value
117
+ )
118
+ end
119
+ end
120
+
121
+ def child?
122
+ !@table_rel.empty?
123
+ end
124
+
125
+ def finalize
126
+ @columns.map { |name, profile| profile.finalize(name, @row_count) }
127
+ end
128
+ end
129
+
130
+ # Collects TableProfiles for every table produced by shredding a file.
131
+ class SchemaInferrer
132
+ attr_reader :profiles
133
+
134
+ def initialize(raw_dates: false)
135
+ @raw_dates = raw_dates
136
+ @profiles = {}
137
+ end
138
+
139
+ def observe(row)
140
+ (
141
+ @profiles[row.table_rel] ||= TableProfile.new(
142
+ row.table_rel,
143
+ raw_dates: @raw_dates
144
+ )
145
+ ).observe_row(row.columns)
146
+ row.children.each { |child| observe(child) }
147
+ end
148
+ end
149
+ end
150
+ end