lowdata 0.1.0 → 0.2.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: 5902c4edccd0a49aec0a211a75c7dd3cbc277721155989ff0d4b9abbd84bb153
4
+ data.tar.gz: cdb2e97726f61229d527d099c62f550e6d35f7dd984359d5a8bc6e62c0826b8d
5
5
  SHA512:
6
- metadata.gz: fabdfb8df5462fd1274a435bcc4a1230f58cafb17f0ebc6ec03a4652b81b86f61ef877d4237c121510325d0a72280a3de0afcf81490aea61facd1dc6336786c9
7
- data.tar.gz: 9311a3e886512bf5eb297690db47fb78ff5f81c9d9c267ab1a5b9a289de94cc784909a4b8bf6104ab6841b56cd9c2e4be8f3a13f2bc14f296448389d3f03cc07
6
+ metadata.gz: 46deb79a98865325e79647f77a0f20a853146dfbcf6cede25a9e77433f8dc291f2ae09fe6d2d63d827b2a0e3e5f1c850d19c040f63cfad0b94b1c4515e93052b
7
+ data.tar.gz: 87560d6313de8eca179b1ac1e6526258ca1fd0d4dc64b1fe24a1c5adb99fbc408bad0e876b9f381001f7d8079f045c5bed9cb1c4adef2ba365cbd6edcc961a2e
@@ -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
@@ -14,27 +14,45 @@ 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)
30
+ # A table with the columns to select, the columns renamed (`headline: :title`), the tables nested in its brackets and
31
+ # the filter on its rows.
32
+ Source = ::Data.define(:table, :columns, :renames, :nested, :filter) do
33
+ def initialize(table:, columns:, renames: {}, nested: [], filter: Filter.new)
31
34
  super
32
35
  end
33
36
  end
37
+
38
+ class Source
39
+ # No columns or renames selects every column.
40
+ def every_column? = columns.empty? && renames.empty?
41
+
42
+ # The keys of each row, as asked for.
43
+ def names = columns + renames.keys
44
+
45
+ # The renamed columns, as `posts.title AS headline`.
46
+ def aliases = renames.map { |name, column| Sequel[table][column].as(name) }
47
+ end
34
48
  # A table nested in another table's brackets, under an optional key: `Reviews[:rating, author: Users[:username]]`.
35
49
  Nested = ::Data.define(:key, :node)
36
- Join = ::Data.define(:type, :left, :right)
37
- Inversion = ::Data.define(:table, :columns, :filter)
50
+ Join = ::Data.define(:type, :left, :right) do
51
+ def nest? = type == :nest
52
+ end
53
+ # Tables nested side by side in the table before them: `Posts[:title] + [Comments[:body], Likes[]]`.
54
+ Siblings = ::Data.define(:nodes)
55
+ Inversion = ::Data.define(:table, :columns, :renames, :filter)
38
56
  # A node sorted by `keys` (Col::Key) with `rows` (Rows) kept: `Posts[:title] ^ -Col[:views] * 10`.
39
57
  Ordered = ::Data.define(:node, :keys, :rows)
40
58
 
@@ -47,15 +65,18 @@ class LowData
47
65
  end
48
66
 
49
67
  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'
51
-
68
+ NESTED_DATASET_MESSAGE = 'Nested tables load with one query per table, so they have no single dataset. Call sql to see ' \
69
+ 'each query, or to_a to load them'
52
70
  def dataset(database: LowData.database)
53
- raise Error, NESTED_SQL_MESSAGE if nested?
71
+ raise Error, NESTED_DATASET_MESSAGE if nested?
54
72
 
55
73
  QueryCompiler.new(database:).compile(node: @node)
56
74
  end
57
75
 
76
+ # Nested tables load with one query per table, so their SQL is each query, separated by semicolons.
58
77
  def sql(database: LowData.database)
78
+ return Nesting.new(database:).sql(node: @node).join(";\n") if nested?
79
+
59
80
  dataset(database:).sql
60
81
  end
61
82
 
@@ -78,7 +99,7 @@ class LowData
78
99
 
79
100
  def nested?
80
101
  node = @node.is_a?(Ordered) ? @node.node : @node
81
- (node.is_a?(Join) && node.type == :nest) || (node.is_a?(Source) && node.nested.any?)
102
+ (node.is_a?(Join) && node.nest?) || (node.is_a?(Source) && node.nested.any?)
82
103
  end
83
104
 
84
105
  # Sorting again adds sort keys after the existing ones, and an expression keeps one limit, range or page size.
@@ -87,6 +108,14 @@ class LowData
87
108
  @node = ordered.with(keys: ordered.keys + keys, rows: Rows.merge(ordered.rows, rows))
88
109
  end
89
110
 
111
+ def add_siblings(expressions)
112
+ invalid = expressions.reject { |expression| expression.is_a?(DataExpression) }
113
+ raise ArgumentError, "Nest data expressions such as Comments[:body], not #{invalid.map(&:inspect).join(', ')}" if invalid.any?
114
+ raise ArgumentError, 'Nest at least one table, e.g. Posts[:title] + [Comments[:body], Likes[]]' if expressions.empty?
115
+
116
+ @node = Join.new(type: :nest, left: @node, right: Siblings.new(nodes: expressions.map(&:node)))
117
+ end
118
+
90
119
  def xor_value(value)
91
120
  raise ArgumentError, format(SORT_MESSAGE, value: value.inspect) unless value.is_a?(Col)
92
121
 
@@ -125,6 +154,8 @@ class LowData
125
154
  end
126
155
 
127
156
  define_method(:"#{prefix}_value") do |value|
157
+ return add_siblings(value) if prefix == :add && value.is_a?(Array)
158
+
128
159
  raise ArgumentError, "Join a data expression such as Posts[:title], not the value #{value.inspect}"
129
160
  end
130
161
 
@@ -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
@@ -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
@@ -13,9 +13,13 @@ require_relative 'order_compiler'
13
13
  require_relative 'filter_compiler'
14
14
  require_relative 'schema'
15
15
  require_relative 'inversion'
16
+ require_relative 'nested_join'
16
17
  require_relative 'nesting'
17
18
  require_relative 'nesting_tree'
19
+ require_relative 'nesting_path'
18
20
  require_relative 'query_compiler'
21
+ require_relative 'writer'
22
+ require_relative 'expression_writes'
19
23
  require_relative 'table_reference'
20
24
 
21
25
  class LowData
@@ -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
61
  root = NestingTree.new(schema: @schema).build(node:)
32
- shape(node: root, rows: fetch(node: root))
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).build(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).select_append(*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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LowData
4
+ # Lays out a + chain for NestingTree. Each operand nests in the operand before it, and an array nests its expressions
5
+ # side by side in the table before it, ending the chain:
6
+ # Users + Posts + Comments # comments in posts, in users
7
+ # Users + [Posts + Comments, Likes] # posts with their comments, and likes, in users
8
+ # Users + Posts + Likes(:user_id) # a named foreign key reaches back: likes in users, beside posts
9
+ # Foreign keys only link adjacent tables, so a foreign key between other tables can't change or break the chain.
10
+ class NestingPath
11
+ ARRAY_END_MESSAGE = 'An array ends a + chain, because it nests several tables. Put the rest of the chain in the ' \
12
+ 'array, e.g. Users + [Posts + Comments, Orders]'
13
+
14
+ # A node waiting for its children to be linked, and the entries from it down to the end of its chain.
15
+ Entry = Struct.new(:node, :children, :tail, :closed) do
16
+ def self.for(node) = new(node, [], nil, false)
17
+
18
+ def path = self[:tail] || [self]
19
+
20
+ def tables = node.tables
21
+ end
22
+
23
+ def initialize(schema:, tree:)
24
+ @schema = schema
25
+ @tree = tree
26
+ end
27
+
28
+ # A + chain is left deep, and + is associative, so a parenthesised chain's operands join the path.
29
+ def self.segments(node:)
30
+ return [node] unless node.is_a?(DataExpression::Join) && node.nest?
31
+
32
+ segments(node: node.left) + segments(node: node.right)
33
+ end
34
+
35
+ # Returns the first segment's entry, with every other segment nested under it.
36
+ def chain(segments:)
37
+ root = @tree.entry(node: segments.first)
38
+ path = segments.drop(1).reduce(root.path) { |entries, segment| attach(path: entries, segment:) }
39
+ root.tap { |entry| entry.tail = path }
40
+ end
41
+
42
+ private
43
+
44
+ # Nests a segment and returns the path to the end of the chain.
45
+ def attach(path:, segment:)
46
+ raise JoinError, ARRAY_END_MESSAGE if path.last.closed
47
+ return siblings(path:, nodes: segment.nodes) if segment.is_a?(DataExpression::Siblings)
48
+
49
+ child = @tree.entry(node: segment)
50
+ parent = parent(path:, child:)
51
+ parent.children << child
52
+ path[0..path.index { |entry| entry.equal?(parent) }] + child.path
53
+ end
54
+
55
+ def siblings(path:, nodes:)
56
+ nodes.each { |node| path.last.children << @tree.entry(node:) }
57
+ path.last.closed = true
58
+ path
59
+ end
60
+
61
+ # The tables before the last, nearest first.
62
+ def earlier(path:) = path[0...-1].reverse
63
+
64
+ # The table before, unless a foreign key named in the child's round brackets points to an earlier one.
65
+ def parent(path:, child:)
66
+ targets = named_targets(child:)
67
+ tables = targets.map(&:last)
68
+ return linked!(path:, child:) if tables.empty? || (path.last.tables & tables).any?
69
+
70
+ earlier(path:).find { |entry| (entry.tables & tables).any? } ||
71
+ raise(JoinError, "#{targets.first.first} points to #{tables.first}, which isn't earlier in this + chain")
72
+ end
73
+
74
+ # Each foreign key named in the child's round brackets, as `table.column` and the table it points to. A key between
75
+ # a join's own tables links the join, not the chain, so it's left out.
76
+ def named_targets(child:)
77
+ child.node.foreign_keys.flat_map do |table, columns|
78
+ keys = @schema.foreign_keys(table:).select { |key| (key[:columns] & columns).any? && !child.tables.include?(key[:table]) }
79
+ keys.map { |key| ["#{table}.#{key[:columns].first}", key[:table]] }
80
+ end
81
+ end
82
+
83
+ # The table before, when a foreign key links the two. Otherwise it suggests nesting the child in an earlier table.
84
+ def linked!(path:, child:)
85
+ parent = path.last
86
+ raise JoinError, "Can't nest #{parent.tables.join(', ')} in itself" if parent.tables == child.tables
87
+ return parent if linked?(child, parent)
88
+
89
+ earlier = earlier(path:).find { |entry| linked?(child, entry) }
90
+ raise JoinError, unlinked_message(child: child.tables.first, parent: parent.tables.last, earlier: earlier&.tables&.last)
91
+ end
92
+
93
+ def linked?(entry, other)
94
+ entry.tables.product(other.tables).any? { |table, other_table| @schema.linked?(table, other_table) }
95
+ end
96
+
97
+ # Suggests an array, or naming the foreign key that reaches back to the earlier table.
98
+ def unlinked_message(child:, parent:, earlier:)
99
+ message = "No foreign key links #{child} to #{parent}"
100
+ return message unless earlier
101
+
102
+ names = [earlier, parent, child].map { |table| LowData.constant_name(table) }
103
+ key = @schema.links(from: child, to: earlier).first
104
+ "#{message}. To nest #{child} in #{earlier}, beside #{parent}, write #{names[0]} + [#{names[1]}, #{names[2]}]" +
105
+ (key ? " or name its foreign key: #{names[2]}(:#{key[:columns].first})" : '')
106
+ end
107
+ end
108
+ end