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 +4 -4
- data/config/rubocop.yml +14 -0
- data/lib/data_expression/source.rb +27 -0
- data/lib/data_expression/writes.rb +45 -0
- data/lib/data_expression.rb +31 -14
- data/lib/filter_compiler.rb +12 -0
- data/lib/inversion.rb +42 -11
- data/lib/lowdata.rb +12 -2
- data/lib/nested_join.rb +82 -0
- data/lib/nesting.rb +83 -20
- data/lib/nesting_path.rb +108 -0
- data/lib/nesting_tree.rb +57 -72
- data/lib/order_compiler.rb +3 -1
- data/lib/query_compiler.rb +58 -25
- data/lib/rows.rb +3 -3
- data/lib/schema.rb +25 -4
- data/lib/table_reference/writes.rb +70 -0
- data/lib/table_reference.rb +61 -14
- data/lib/version.rb +1 -1
- data/lib/write_checks.rb +78 -0
- data/lib/writer.rb +155 -0
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00edcbe16f363ff5571130711b163c2b7a30f269b063237d9513f0d0d65b1ac9
|
|
4
|
+
data.tar.gz: b76da81116417a88cb6d440acfcd676aee2f9f53726c8b079551e4ab6df5279f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76724feffd8a520b11e60bd387ebf1a3aa7543fc7bb962af8b4b5db4411f9099fa42cef7207c98592cf6687a393076b51f3ffad45b198e32b885f8636accac93
|
|
7
|
+
data.tar.gz: f555c5e21c68df568a8e02e511f9fd6ee8ced5ca9a97edc799eb95415e96fb56c38b2711eb161546fe7c00b775905c1418127b146caddb474a96c9ede039062b
|
data/config/rubocop.yml
ADDED
|
@@ -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
|
data/lib/data_expression.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
data/lib/filter_compiler.rb
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
data/lib/nested_join.rb
ADDED
|
@@ -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
|
-
|
|
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).
|
|
32
|
-
shape(node: root, rows:
|
|
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
|
|
38
|
-
dataset
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
|
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.
|
|
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.
|
|
153
|
+
values = rows.map { |row| row[child.link.parent_key] }.compact.uniq
|
|
92
154
|
return {} if values.empty?
|
|
93
155
|
|
|
94
|
-
child_rows =
|
|
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[
|
|
159
|
+
.group_by { |row, _output| row[link_column] }
|
|
97
160
|
.transform_values { |pairs| pairs.map(&:last) }
|
|
98
161
|
end
|
|
99
162
|
end
|