lowdata 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c2e8629cffb84f30179cf393797a47d5cb05f7f03091a2b2cc122d8053593a4
4
- data.tar.gz: 1468121aa2d7c393a167f890ad009da216191ed2da0d83371ce33e05f3cc4100
3
+ metadata.gz: 00edcbe16f363ff5571130711b163c2b7a30f269b063237d9513f0d0d65b1ac9
4
+ data.tar.gz: b76da81116417a88cb6d440acfcd676aee2f9f53726c8b079551e4ab6df5279f
5
5
  SHA512:
6
- metadata.gz: fabdfb8df5462fd1274a435bcc4a1230f58cafb17f0ebc6ec03a4652b81b86f61ef877d4237c121510325d0a72280a3de0afcf81490aea61facd1dc6336786c9
7
- data.tar.gz: 9311a3e886512bf5eb297690db47fb78ff5f81c9d9c267ab1a5b9a289de94cc784909a4b8bf6104ab6841b56cd9c2e4be8f3a13f2bc14f296448389d3f03cc07
6
+ metadata.gz: 76724feffd8a520b11e60bd387ebf1a3aa7543fc7bb962af8b4b5db4411f9099fa42cef7207c98592cf6687a393076b51f3ffad45b198e32b885f8636accac93
7
+ data.tar.gz: f555c5e21c68df568a8e02e511f9fd6ee8ced5ca9a97edc799eb95415e96fb56c38b2711eb161546fe7c00b775905c1418127b146caddb474a96c9ede039062b
@@ -0,0 +1,14 @@
1
+ # Inherit in an app that writes with LowData, so RuboCop's autocorrect can't change or remove a write:
2
+ #
3
+ # inherit_gem:
4
+ # lowdata: config/rubocop.yml
5
+ #
6
+ # See ADR 0007.
7
+
8
+ # `Posts(slug: 'hello') <= row` on its own line looks unused, and `rubocop -A` deletes the `<= row`, removing the upsert.
9
+ Lint/Void:
10
+ Enabled: false
11
+
12
+ # `Tags <= row` looks like a comparison with the constant first, and `rubocop -A` reverses it to `row >= Tags`.
13
+ Style/YodaCondition:
14
+ Enabled: false
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ class DataExpression < ::Expressions::Expression
5
+ # A table with the columns to select, the columns renamed (`headline: :title`), the tables nested in its brackets and
6
+ # the filter on its rows. `bare` is a table without brackets, `Posts`, which selects every column. See ADR 0006.
7
+ Source = ::Data.define(:table, :columns, :renames, :nested, :filter, :bare) do
8
+ def initialize(table:, columns:, renames: {}, nested: [], filter: Filter.new, bare: false)
9
+ super
10
+ end
11
+ end
12
+
13
+ class Source
14
+ # A bare table, `Posts`, selects every column.
15
+ def every_column? = bare
16
+
17
+ # Empty brackets, `Posts[]`, select no columns.
18
+ def no_columns? = !bare && columns.empty? && renames.empty?
19
+
20
+ # The keys of each row, as asked for.
21
+ def names = columns + renames.keys
22
+
23
+ # The renamed columns, as `posts.title AS headline`.
24
+ def aliases = renames.map { |name, column| Sequel[table][column].as(name) }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ class DataExpression < ::Expressions::Expression
5
+ # Writes through a table in square brackets, which choose the columns returned. See Writer:
6
+ # Posts[:id, :created_at] << { title: 'Hello' } # Inserts, returning the new row's id and created_at.
7
+ # Posts(slug: 'hello')[:id] <= { title: 'Hello' } # Upserts, returning the row's id.
8
+ module Writes
9
+ # It runs straight away and returns the rows, so it overrides the expressions gem's `<=`, which builds a copy.
10
+ def <=(rows)
11
+ Writer.new(database: LowData.database).upsert(source: write_source(operator: '<='), rows:)
12
+ end
13
+
14
+ def delete
15
+ raise ArgumentError, 'Square brackets choose columns, and a delete removes whole rows. Delete from the table, e.g. ' \
16
+ 'Posts(id: 1).delete'
17
+ end
18
+
19
+ private
20
+
21
+ # Writes go to one table.
22
+ def write_source(operator:)
23
+ return @node if @node.is_a?(Source) && @node.nested.empty?
24
+
25
+ raise ArgumentError, "#{operator} writes to one table, e.g. Posts[:id] #{operator} { title: 'Hello' }, not to joined, " \
26
+ 'nested or sorted tables'
27
+ end
28
+
29
+ # The expressions gem's `<<` calls this on the expression itself and returns what it returns, the inserted rows.
30
+ def append_value(rows)
31
+ Writer.new(database: LowData.database).insert(source: write_source(operator: '<<'), rows:)
32
+ end
33
+
34
+ def append_expression(_expression)
35
+ raise ArgumentError, WriteChecks::EXPRESSION_MESSAGES.fetch('<<')
36
+ end
37
+
38
+ def append_type(type_)
39
+ raise ArgumentError, "<< inserts rows, e.g. Posts << { title: 'Hello' }, not the type #{type_}"
40
+ end
41
+ end
42
+
43
+ include Writes
44
+ end
45
+ end
@@ -14,27 +14,27 @@ class LowData
14
14
  # (Posts[:title] / 20)[2] # LIMIT 20 OFFSET 40
15
15
  class DataExpression < ::Expressions::Expression
16
16
  # The filters in a table's round brackets: `Posts(published: true) { views > 100 }`. See FilterCompiler.
17
- Filter = ::Data.define(:values, :conditions, :related) do
18
- def initialize(values: {}, conditions: [], related: [])
17
+ # `foreign_keys` aren't filters: they name the foreign key a nested join links by, `Posts(:user_id)`. See NestedJoin.
18
+ Filter = ::Data.define(:values, :conditions, :related, :foreign_keys) do
19
+ def initialize(values: {}, conditions: [], related: [], foreign_keys: [])
19
20
  super
20
21
  end
21
22
 
22
23
  def merge(other)
23
- with(values: values.merge(other.values), conditions: conditions + other.conditions, related: related + other.related)
24
+ with(values: values.merge(other.values), conditions: conditions + other.conditions, related: related + other.related,
25
+ foreign_keys: foreign_keys + other.foreign_keys)
24
26
  end
25
27
  end
26
28
  # A table in another table's round brackets, keeping rows with `count` related rows: `Users(Posts => 3..)`.
27
29
  Related = ::Data.define(:key, :source, :count)
28
- # A table with the columns to select, the tables nested in its brackets and the filter on its rows.
29
- Source = ::Data.define(:table, :columns, :nested, :filter) do
30
- def initialize(table:, columns:, nested: [], filter: Filter.new)
31
- super
32
- end
33
- end
34
30
  # A table nested in another table's brackets, under an optional key: `Reviews[:rating, author: Users[:username]]`.
35
31
  Nested = ::Data.define(:key, :node)
36
- Join = ::Data.define(:type, :left, :right)
37
- Inversion = ::Data.define(:table, :columns, :filter)
32
+ Join = ::Data.define(:type, :left, :right) do
33
+ def nest? = type == :nest
34
+ end
35
+ # Tables nested side by side in the table before them: `Posts[:title] + [Comments[:body], Likes]`.
36
+ Siblings = ::Data.define(:nodes)
37
+ Inversion = ::Data.define(:table, :columns, :renames, :filter)
38
38
  # A node sorted by `keys` (Col::Key) with `rows` (Rows) kept: `Posts[:title] ^ -Col[:views] * 10`.
39
39
  Ordered = ::Data.define(:node, :keys, :rows)
40
40
 
@@ -47,15 +47,19 @@ class LowData
47
47
  end
48
48
 
49
49
  SORT_MESSAGE = 'Sort by columns with Col[], e.g. Posts[:title] ^ Col[:views], not %<value>s'
50
- NESTED_SQL_MESSAGE = '+ loads nested data with one query per table, so it has no single SQL query. Call to_a instead'
50
+ NESTED_DATASET_MESSAGE = 'Nested tables load with one query per table, so they have no single dataset. Call sql to see ' \
51
+ 'each query, or to_a to load them'
51
52
 
52
53
  def dataset(database: LowData.database)
53
- raise Error, NESTED_SQL_MESSAGE if nested?
54
+ raise Error, NESTED_DATASET_MESSAGE if nested?
54
55
 
55
56
  QueryCompiler.new(database:).compile(node: @node)
56
57
  end
57
58
 
59
+ # Nested tables load with one query per table, so their SQL is each query, separated by semicolons.
58
60
  def sql(database: LowData.database)
61
+ return Nesting.new(database:).sql(node: @node).join(";\n") if nested?
62
+
59
63
  dataset(database:).sql
60
64
  end
61
65
 
@@ -78,7 +82,7 @@ class LowData
78
82
 
79
83
  def nested?
80
84
  node = @node.is_a?(Ordered) ? @node.node : @node
81
- (node.is_a?(Join) && node.type == :nest) || (node.is_a?(Source) && node.nested.any?)
85
+ (node.is_a?(Join) && node.nest?) || (node.is_a?(Source) && node.nested.any?)
82
86
  end
83
87
 
84
88
  # Sorting again adds sort keys after the existing ones, and an expression keeps one limit, range or page size.
@@ -87,6 +91,15 @@ class LowData
87
91
  @node = ordered.with(keys: ordered.keys + keys, rows: Rows.merge(ordered.rows, rows))
88
92
  end
89
93
 
94
+ def add_siblings(expressions)
95
+ expressions = expressions.map { |expression| TableReference.expression(expression) }
96
+ invalid = expressions.reject { |expression| expression.is_a?(DataExpression) }
97
+ raise ArgumentError, "Nest data expressions such as Comments[:body], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
98
+ raise ArgumentError, 'Nest at least one table, e.g. Posts[:title] + [Comments[:body], Likes]' if expressions.empty?
99
+
100
+ @node = Join.new(type: :nest, left: @node, right: Siblings.new(nodes: expressions.map(&:node)))
101
+ end
102
+
90
103
  def xor_value(value)
91
104
  raise ArgumentError, format(SORT_MESSAGE, value: value.inspect) unless value.is_a?(Col)
92
105
 
@@ -124,7 +137,11 @@ class LowData
124
137
  @node = Join.new(type:, left: @node, right: expression.node)
125
138
  end
126
139
 
140
+ # A bare table on the right, `Users[:username] + Posts`, joins as every column of the table.
127
141
  define_method(:"#{prefix}_value") do |value|
142
+ return send(:"#{prefix}_expression", value.to_expression) if value.is_a?(TableReference)
143
+ return add_siblings(value) if prefix == :add && value.is_a?(Array)
144
+
128
145
  raise ArgumentError, "Join a data expression such as Posts[:title], not the value #{value.inspect}"
129
146
  end
130
147
 
@@ -9,15 +9,27 @@ class LowData
9
9
  class FilterCompiler
10
10
  def initialize(database:, schema:)
11
11
  @database = database
12
+ @schema = schema
12
13
  @links = NestingTree.new(schema:)
13
14
  end
14
15
 
15
16
  def apply(dataset:, source:)
17
+ check_foreign_keys!(source:)
16
18
  conditions(source:).reduce(dataset) { |filtered, condition| filtered.where(condition) }
17
19
  end
18
20
 
19
21
  private
20
22
 
23
+ # A symbol in round brackets names a foreign key for NestedJoin, so it must be one of the table's.
24
+ def check_foreign_keys!(source:)
25
+ foreign_keys = @schema.foreign_keys(table: source.table).flat_map { |foreign_key| foreign_key[:columns] }
26
+ invalid = (source.filter.foreign_keys - foreign_keys).first
27
+ return unless invalid
28
+
29
+ raise ArgumentError, "#{source.table} has no #{invalid} foreign key. A symbol in round brackets names the foreign key " \
30
+ 'a nested join links by, e.g. Posts(:user_id). Filters are keywords, e.g. Posts(published: true)'
31
+ end
32
+
21
33
  def conditions(source:)
22
34
  table = source.table
23
35
  filter = source.filter
data/lib/inversion.rb CHANGED
@@ -7,30 +7,61 @@ class LowData
7
7
  @schema = schema
8
8
  end
9
9
 
10
- # Returns the join tree and the columns to select, in the order they were asked for.
10
+ # Returns the join tree and the columns to select, in the order they were asked for, then the renamed columns.
11
11
  def resolve(node:)
12
- owners = node.columns.to_h { |column| [column, owner(table: node.table, column:)] }
13
- base = DataExpression::Source.new(table: node.table, columns: [], filter: node.filter)
12
+ owned = node.columns.map { |column| [owner(table: node.table, column:), column, nil] } +
13
+ node.renames.map { |name, column| [renamed_owner(table: node.table, name:, column:), column, name] }
14
+ selections = owned.map { |table, column, name| name ? Sequel[table][column].as(name) : Sequel[table][column] }
14
15
 
15
- tree = (owners.values.uniq - [node.table]).reduce(base) do |left, table|
16
- DataExpression::Join.new(type: :left, left:, right: DataExpression::Source.new(table:, columns: []))
17
- end
18
-
19
- [tree, owners.map { |column, table| Sequel[table][column] }]
16
+ [tree(node:, tables: owned.map(&:first)), selections]
20
17
  end
21
18
 
22
19
  private
23
20
 
24
21
  def owner(table:, column:)
25
- return table if @schema.columns(table:).include?(column)
26
-
27
- owners = @schema.related_tables(table:).select { |related| @schema.columns(table: related).include?(column) }
22
+ owners = owners(table:, column:)
28
23
  raise JoinError, "No table related to #{table} has a #{column} column" if owners.empty?
29
24
  raise JoinError, ambiguous_message(table:, column:, owners:) if owners.length > 1
30
25
 
31
26
  owners.first
32
27
  end
33
28
 
29
+ def owners(table:, column:)
30
+ return [table] if @schema.columns(table:).include?(column)
31
+
32
+ @schema.related_tables(table:).select { |related| @schema.columns(table: related).include?(column) }
33
+ end
34
+
35
+ # The owner of a renamed column, which mustn't also have a column with the new name.
36
+ def renamed_owner(table:, name:, column:)
37
+ if owners(table:, column:).empty? && owners(table:, column: name).any?
38
+ raise ArgumentError, "No table related to #{table} has a #{column} column. To rename #{name}, write #{column}: :#{name}"
39
+ end
40
+
41
+ owner(table:, column:).tap { |owner| @schema.check_renames!(table: owner, renames: { name => column }) }
42
+ end
43
+
44
+ # The repository's table left joined to each other table that owns a column. Joins link to the table before them,
45
+ # so each table names the foreign keys that link it to the repository's table, on whichever side they are.
46
+ def tree(node:, tables:)
47
+ owners = tables.uniq - [node.table]
48
+ base_keys = owners.flat_map { |table| link_columns(from: node.table, to: table) }
49
+ base = DataExpression::Source.new(table: node.table, columns: [], filter: node.filter.merge(named(base_keys)))
50
+
51
+ owners.reduce(base) do |left, table|
52
+ right = DataExpression::Source.new(table:, columns: [], filter: named(link_columns(from: table, to: node.table)))
53
+ DataExpression::Join.new(type: :left, left:, right:)
54
+ end
55
+ end
56
+
57
+ def link_columns(from:, to:)
58
+ @schema.links(from:, to:).flat_map { |link| link[:columns] }
59
+ end
60
+
61
+ def named(foreign_keys)
62
+ DataExpression::Filter.new(foreign_keys:)
63
+ end
64
+
34
65
  def ambiguous_message(table:, column:, owners:)
35
66
  "#{owners.join(' and ')} both have a #{column} column and are related to #{table}. " \
36
67
  "Join explicitly instead, e.g. #{LowData.constant_name(table)}[] < #{LowData.constant_name(owners.first)}[:#{column}]"
data/lib/lowdata.rb CHANGED
@@ -7,16 +7,22 @@ require 'sequel'
7
7
  require_relative 'version'
8
8
  require_relative 'errors'
9
9
  require_relative 'data_expression'
10
+ require_relative 'data_expression/source'
10
11
  require_relative 'rows'
11
12
  require_relative 'col'
12
13
  require_relative 'order_compiler'
13
14
  require_relative 'filter_compiler'
14
15
  require_relative 'schema'
15
16
  require_relative 'inversion'
17
+ require_relative 'nested_join'
16
18
  require_relative 'nesting'
17
19
  require_relative 'nesting_tree'
20
+ require_relative 'nesting_path'
18
21
  require_relative 'query_compiler'
22
+ require_relative 'writer'
23
+ require_relative 'data_expression/writes'
19
24
  require_relative 'table_reference'
25
+ require_relative 'table_reference/writes'
20
26
 
21
27
  class LowData
22
28
  class << self
@@ -68,8 +74,12 @@ class LowData
68
74
  # Round brackets after a table filter it: `Posts(published: true) { views > 100 }[:title]`. A capitalised name is a
69
75
  # table only when the database has that table, so typos still raise NoMethodError. `Table(...)` is always a table.
70
76
  def method_missing(name, *arguments, **keywords, &block)
71
- raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil? && name.match?(/\A[A-Z]/)
72
- return super unless table_method?(name:)
77
+ return super unless name.match?(/\A[A-Z]/)
78
+ raise ConfigError, ConfigError::MISSING_DATABASE if LowData.database.nil?
79
+
80
+ # Raised rather than left to super, which can report a NameError instead when looking up the tables runs a
81
+ # method_missing of its own, as Postgres' adapter does.
82
+ raise NoMethodError.new("undefined method '#{name}' for an instance of #{self.class}", name) unless table_method?(name:)
73
83
 
74
84
  self.class.const_missing(name).where(*arguments, **keywords, &block)
75
85
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # A join in a nesting tree, loaded with one query by QueryCompiler. It nests as flat rows, and tables nest under each
5
+ # of its rows, through its table written next to the +: its first table after a +, and its last table before one.
6
+ # Users[:username] + (Posts[:title] & Comments[:body]) # => [{ username: 'alice', posts: [{ title: 'Hello', body: 'Nice' }] }]
7
+ # (Users[:username] & Posts[:title]) + Comments[:body] # => [{ username: 'alice', title: 'Hello', comments: [{ body: 'Nice' }] }]
8
+ #
9
+ # A foreign key named in round brackets links through another of its tables:
10
+ # Users[:username] + (Posts[:title] & Likes(:user_id)[:id]) # Links through likes.user_id.
11
+ NestedJoin = ::Data.define(:node, :tables, :table, :foreign_keys)
12
+
13
+ class NestedJoin
14
+ def self.build(node:)
15
+ sources = sources_in(node:)
16
+ foreign_keys = sources.to_h { |source| [source.table, source.filter.foreign_keys] }
17
+ new(node:, tables: sources.map(&:table), table: nil, foreign_keys:)
18
+ end
19
+
20
+ # A join holds only tables joined with &, <, > or <=>, as QueryCompiler requires.
21
+ def self.sources_in(node:)
22
+ raise JoinError, QueryCompiler::INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
23
+ raise JoinError, QueryCompiler::ORDERED_JOIN_MESSAGE if node.is_a?(DataExpression::Ordered)
24
+ return [node] if node.is_a?(DataExpression::Source)
25
+ raise JoinError, QueryCompiler::NESTING_JOIN_MESSAGE if node.nest?
26
+
27
+ sources_in(node: node.left) + sources_in(node: node.right)
28
+ end
29
+
30
+ # Returns the join with its table, and the key and Link that nest it under `parent`. Only the "many" side can nest a
31
+ # join, because joined rows can repeat the table's rows.
32
+ def link(parent:, key:, tree:, schema:, foreign_keys:)
33
+ table = linked_table(others: [parent], schema:, foreign_keys:, nested: true)
34
+ key, link = tree.link_to(parent:, child: table, key:)
35
+ raise JoinError, one_side_message(parent:, table:) unless link.many
36
+
37
+ [with(table:), key, link]
38
+ end
39
+
40
+ # The table the join links to `others` through: the one written next to the +, unless a foreign key named in round
41
+ # brackets links another. `foreign_keys` are the named foreign keys of `others`, by table. `nested` is true when the
42
+ # join nests in `others`, so the + is before its first table, and false when `others` nest in it, after its last.
43
+ def linked_table(others:, schema:, foreign_keys:, nested: false)
44
+ named = named(others:, schema:, foreign_keys: self.foreign_keys.merge(foreign_keys))
45
+ table = named.first || (nested ? tables.first : tables.last)
46
+ return table if others.any? { |other| schema.linked?(table, other) }
47
+
48
+ raise JoinError, unlinked_message(table:, others:, schema:)
49
+ end
50
+
51
+ private
52
+
53
+ # The join's tables whose foreign key to `others`, on either side, is named in round brackets.
54
+ def named(others:, schema:, foreign_keys:)
55
+ tables.select do |table|
56
+ others.any? do |other|
57
+ columns = (schema.links(from: table, to: other) + schema.links(from: other, to: table)).flat_map { |link| link[:columns] }
58
+ (columns & (foreign_keys.fetch(table, []) + foreign_keys.fetch(other, []))).any?
59
+ end
60
+ end
61
+ end
62
+
63
+ # Suggests naming the foreign key of another of the join's tables that links, on the table that has it.
64
+ def unlinked_message(table:, others:, schema:)
65
+ names = others.join(', ')
66
+ choices = (tables - [table]).product(others).flat_map do |linked, other|
67
+ [[linked, other], [other, linked]].flat_map do |from, to|
68
+ schema.links(from:, to:).map { |link| "#{LowData.constant_name(from)}(:#{link[:columns].first})" }
69
+ end
70
+ end
71
+ message = "No foreign key links #{table} to #{names}, and a join links through its table written next to the +"
72
+ return message if choices.empty?
73
+
74
+ "#{message}. Write the table that links next to the +, or name its foreign key: #{choices.sort.join(' or ')}"
75
+ end
76
+
77
+ def one_side_message(parent:, table:)
78
+ "#{parent} points to #{table}, so a join with #{table} can't nest in #{parent}, because its rows can repeat " \
79
+ "#{table}. Nest #{table} in brackets instead"
80
+ end
81
+ end
82
+ end
data/lib/nesting.rb CHANGED
@@ -5,18 +5,47 @@ class LowData
5
5
  # Posts[:title, Users[:username]] # => [{ title: 'Hello', user: { username: 'alice' } }]
6
6
  # Users[:username] + Posts[:title] # => [{ username: 'alice', posts: [{ title: 'Hello' }] }]
7
7
  # In a + chain the first table is the subject, and each other operand nests under the table its foreign key links it to.
8
+ # A join nests as flat rows, loaded with one query, and tables can nest under each of its rows: see NestedJoin.
8
9
  class Nesting
9
10
  # A table in the nesting tree. `link` connects it to its parent node: `parent_column` on the parent's rows matches
10
11
  # `child_column` on this table's rows, and `many` nests the matches as an array rather than one object.
11
- # `keys` and `rows` sort and limit the table's rows, per parent when it's nested.
12
+ # `keys` and `rows` sort and limit the table's rows, per parent when it's nested. `source` is a table or a NestedJoin.
12
13
  Node = ::Data.define(:source, :key, :link, :children, :keys, :rows) do
13
14
  def initialize(source:, key:, link:, children:, keys: [], rows: nil)
14
15
  super
15
16
  end
16
17
  end
17
- Link = ::Data.define(:parent_column, :child_column, :many)
18
+
19
+ class Node
20
+ def join
21
+ source if source.is_a?(NestedJoin)
22
+ end
23
+
24
+ def tables
25
+ join ? join.tables : [source.table]
26
+ end
27
+
28
+ # The foreign keys named in round brackets, by table: `Posts(:user_id)`.
29
+ def foreign_keys
30
+ join ? join.foreign_keys : { source.table => source.filter.foreign_keys }
31
+ end
32
+ end
33
+ # `parent_table` is set when the parent is a join, whose rows hold `parent_column` under `parent_key`.
34
+ Link = ::Data.define(:parent_column, :child_column, :many, :parent_table) do
35
+ def initialize(parent_column:, child_column:, many:, parent_table: nil)
36
+ super
37
+ end
38
+
39
+ def parent_key
40
+ parent_table ? :"lowdata_#{parent_table}_#{parent_column}" : parent_column
41
+ end
42
+ end
18
43
 
19
44
  ROW_NUMBER = :lowdata_row_number
45
+ # Stands in for the parent values a nested table's query is filtered by, which aren't known until the parents load.
46
+ PARENT_VALUES = Sequel.lit('(...)')
47
+ # A nested join's link value, selected under its own name so it can't clash with the joined columns.
48
+ LINK = :lowdata_link
20
49
 
21
50
  def initialize(database:)
22
51
  raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
@@ -25,34 +54,60 @@ class LowData
25
54
  @schema = Schema.new(database:)
26
55
  @filters = FilterCompiler.new(database:, schema: @schema)
27
56
  @orders = OrderCompiler.new(schema: @schema)
57
+ @queries = QueryCompiler.new(database:)
28
58
  end
29
59
 
30
60
  def load(node:)
31
- root = NestingTree.new(schema: @schema).build(node:)
32
- shape(node: root, rows: fetch(node: root))
61
+ root = NestingTree.new(schema: @schema).root(node:)
62
+ shape(node: root, rows: dataset(node: root).all)
63
+ end
64
+
65
+ # The SQL of each query `load` runs, parents first, without running them. A nested table's query shows the parent
66
+ # values it's filtered by as `IN (...)`.
67
+ def sql(node:)
68
+ queries(node: NestingTree.new(schema: @schema).root(node:)).map(&:sql)
33
69
  end
34
70
 
35
71
  private
36
72
 
37
- def fetch(node:, filter: nil)
38
- dataset = @filters.apply(dataset: @database[node.source.table].select(*select_columns(node:)), source: node.source)
39
- dataset = dataset.where(filter) if filter
40
- return per_parent(node:, dataset:) if filter && node.rows
73
+ def queries(node:, values: nil)
74
+ [dataset(node:, values:), *node.children.flat_map { |child| queries(node: child, values: PARENT_VALUES) }]
75
+ end
76
+
77
+ # A node's rows, or when it's nested, its rows linked to the parents' `values`.
78
+ def dataset(node:, values: nil)
79
+ dataset = node.join ? join_dataset(node:) : table_dataset(node:)
80
+ dataset = dataset.where(Sequel::SQL::BooleanExpression.new(:IN, link_column(node:), values)) if values
81
+ return per_parent(node:, dataset:) if values && node.rows
82
+
83
+ @orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: node.tables)
84
+ end
85
+
86
+ def table_dataset(node:)
87
+ @schema.check_renames!(table: node.source.table, renames: node.source.renames)
88
+ @filters.apply(dataset: @database[node.source.table].select(*select_columns(node:)), source: node.source)
89
+ end
90
+
91
+ # A join's rows, with the columns its parent and children link by selected under LINK and each Link#parent_key.
92
+ def join_dataset(node:)
93
+ parents = node.children.map { |child| Sequel[child.link.parent_table][child.link.parent_column].as(child.link.parent_key) }
94
+ links = node.link ? [link_column(node:).as(LINK)] : []
95
+ @queries.compile(node: node.join.node, links: [*parents.uniq, *links])
96
+ end
41
97
 
42
- @orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: [node.source.table]).all
98
+ def link_column(node:)
99
+ node.join ? Sequel[node.source.table][node.link.child_column] : node.link.child_column
43
100
  end
44
101
 
45
102
  # Keeps each parent's rows, e.g. each user's 3 most viewed posts, by numbering the rows within each parent.
46
103
  def per_parent(node:, dataset:)
47
104
  numbered = dataset.select_append(row_number(node:).as(ROW_NUMBER))
48
105
  @database.from(numbered).where(ROW_NUMBER => row_numbers(rows: node.rows)).order(ROW_NUMBER)
49
- .all.map { |row| row.except(ROW_NUMBER) }
50
106
  end
51
107
 
52
108
  def row_number(node:)
53
- table = node.source.table
54
- order = @orders.order(keys: node.keys, tables: [table])
55
- Sequel.function(:row_number).over(partition: Sequel[table][node.link.child_column], order: order.empty? ? nil : order)
109
+ order = @orders.order(keys: node.keys, tables: node.tables)
110
+ Sequel.function(:row_number).over(partition: Sequel[node.source.table][node.link.child_column], order: order.empty? ? nil : order)
56
111
  end
57
112
 
58
113
  # Row numbers start at 1, and rows count from 0.
@@ -64,10 +119,11 @@ class LowData
64
119
 
65
120
  # The columns asked for, plus the columns each link needs.
66
121
  def select_columns(node:)
67
- return [Sequel[node.source.table].*] if node.source.columns.empty?
122
+ source = node.source
123
+ return [Sequel[source.table].*] if source.every_column?
68
124
 
69
125
  link_columns = [node.link&.child_column, *node.children.map { |child| child.link.parent_column }].compact
70
- (node.source.columns + link_columns).uniq
126
+ (source.columns + link_columns).uniq + source.aliases
71
127
  end
72
128
 
73
129
  # Turns fetched rows into output hashes: the columns asked for, plus each child nested under its key.
@@ -75,25 +131,32 @@ class LowData
75
131
  nested = node.children.to_h { |child| [child, nested_rows(child:, rows:)] }
76
132
 
77
133
  rows.map do |row|
78
- output = node.source.columns.empty? ? row.dup : row.slice(*node.source.columns)
134
+ output = columns(node:, row:)
79
135
  node.children.each { |child| output[child.key] = matches(child:, row:, nested: nested[child]) }
80
136
  output
81
137
  end
82
138
  end
83
139
 
140
+ def columns(node:, row:)
141
+ return row.except(LINK, ROW_NUMBER, *node.children.map { |child| child.link.parent_key }) if node.join
142
+
143
+ node.source.every_column? ? row.except(ROW_NUMBER) : row.slice(*node.source.names)
144
+ end
145
+
84
146
  def matches(child:, row:, nested:)
85
- matches = nested.fetch(row[child.link.parent_column], [])
147
+ matches = nested.fetch(row[child.link.parent_key], [])
86
148
  child.link.many ? matches : matches.first
87
149
  end
88
150
 
89
151
  # Fetches a child's rows for all parent rows in one query, grouped by the value that links them.
90
152
  def nested_rows(child:, rows:)
91
- values = rows.map { |row| row[child.link.parent_column] }.compact.uniq
153
+ values = rows.map { |row| row[child.link.parent_key] }.compact.uniq
92
154
  return {} if values.empty?
93
155
 
94
- child_rows = fetch(node: child, filter: { child.link.child_column => values })
156
+ child_rows = dataset(node: child, values:).all
157
+ link_column = child.join ? LINK : child.link.child_column
95
158
  child_rows.zip(shape(node: child, rows: child_rows))
96
- .group_by { |row, _output| row[child.link.child_column] }
159
+ .group_by { |row, _output| row[link_column] }
97
160
  .transform_values { |pairs| pairs.map(&:last) }
98
161
  end
99
162
  end