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
data/lib/nesting_path.rb
ADDED
|
@@ -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
|
data/lib/nesting_tree.rb
CHANGED
|
@@ -1,113 +1,98 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class LowData
|
|
4
|
-
# Builds the tree Nesting loads from an expression. Tables in brackets nest directly under their table, and
|
|
5
|
-
#
|
|
4
|
+
# Builds the tree Nesting loads from an expression. Tables in brackets nest directly under their table, and each
|
|
5
|
+
# operand of a + chain nests in the operand before it: see NestingPath. A join nests as flat rows: see NestedJoin.
|
|
6
6
|
class NestingTree
|
|
7
|
-
MULTIPLE_PATHS_MESSAGE = 'Foreign keys link %<tables>s by more than one path, so + has no single way to nest them. ' \
|
|
8
|
-
'Nest them in brackets instead, e.g. Users[:username, Posts[:title, Likes[]]]'
|
|
9
|
-
|
|
10
7
|
def initialize(schema:)
|
|
11
8
|
@schema = schema
|
|
9
|
+
@path = NestingPath.new(schema:, tree: self)
|
|
12
10
|
end
|
|
13
11
|
|
|
14
|
-
# Builds the tree for an expression: a table with any tables nested in its brackets, or a + chain.
|
|
15
12
|
def build(node:)
|
|
13
|
+
assemble(entry: entry(node:))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The tree whose first table is returned, so empty brackets, which select none of its columns, raise.
|
|
17
|
+
def root(node:)
|
|
18
|
+
root = build(node:)
|
|
19
|
+
raise ArgumentError, QueryCompiler.no_columns_message(table: root.source.table) if !root.join && root.source.no_columns?
|
|
20
|
+
|
|
21
|
+
root
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# An expression's node, waiting for the children NestingPath nests in it.
|
|
25
|
+
def entry(node:)
|
|
16
26
|
raise JoinError, QueryCompiler::INVERSION_JOIN_MESSAGE if node.is_a?(DataExpression::Inversion)
|
|
17
|
-
return
|
|
18
|
-
return bracketed(source: node) if node.is_a?(DataExpression::Source)
|
|
27
|
+
return ordered(node:) if node.is_a?(DataExpression::Ordered)
|
|
28
|
+
return NestingPath::Entry.for(bracketed(source: node)) if node.is_a?(DataExpression::Source)
|
|
29
|
+
return NestingPath::Entry.for(Nesting::Node.new(source: NestedJoin.build(node:), key: nil, link: nil, children: [])) unless node.nest?
|
|
19
30
|
|
|
20
|
-
chain(
|
|
31
|
+
@path.chain(segments: NestingPath.segments(node:))
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
# Returns the nested key and Link for a child table. On the "one" side a key matching a foreign key column picks it,
|
|
24
|
-
# so `author:` follows `author_id
|
|
25
|
-
|
|
35
|
+
# so `author:` follows `author_id`, and a foreign key column named in round brackets picks between several.
|
|
36
|
+
# FilterCompiler links related tables the same way.
|
|
37
|
+
def link_to(parent:, child:, key:, named: {})
|
|
26
38
|
to_one = @schema.links(from: parent, to: child)
|
|
27
|
-
|
|
28
|
-
return one(parent:, child:, link:
|
|
39
|
+
named_link = named_link(links: to_one, key:)
|
|
40
|
+
return one(parent:, child:, link: named_link, key:) if named_link
|
|
29
41
|
|
|
30
|
-
links = to_one + @schema.links(from: child, to: parent)
|
|
42
|
+
links = picked(links: to_one + @schema.links(from: child, to: parent), named:)
|
|
31
43
|
raise JoinError, "No foreign key links #{child} to #{parent}" if links.empty?
|
|
32
44
|
raise JoinError, many_keys_message(parent:, child:, to_one:) if links.length > 1
|
|
33
|
-
return one(parent:, child:, link: links.first, key:) if to_one.
|
|
45
|
+
return one(parent:, child:, link: links.first, key:) if to_one.include?(links.first)
|
|
34
46
|
|
|
35
47
|
many(parent:, child:, link: links.first, key:)
|
|
36
48
|
end
|
|
37
49
|
|
|
38
50
|
private
|
|
39
51
|
|
|
40
|
-
# A
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
raise JoinError, QueryCompiler::NESTING_JOIN_MESSAGE unless node.type == :nest
|
|
44
|
-
|
|
45
|
-
operands(node: node.left) + [node.right]
|
|
52
|
+
# A sort or limit applies to the first table of what it sorts.
|
|
53
|
+
def ordered(node:)
|
|
54
|
+
entry(node: node.node).tap { |entry| entry.node = entry.node.with(keys: node.keys, rows: node.rows) }
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
#
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
key, link = link_to(parent: source.table, child: child.source.table, key: nested.key)
|
|
53
|
-
child.with(key:, link:)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
with_children(node: Nesting::Node.new(source:, key: nil, link: nil, children: []), children:)
|
|
57
|
+
# The links whose foreign key is named in round brackets, or all of them if none is.
|
|
58
|
+
def picked(links:, named:)
|
|
59
|
+
picked = links.select { |link| named.values.flatten.include?(link[:columns].first) }
|
|
60
|
+
picked.any? ? picked : links
|
|
57
61
|
end
|
|
58
62
|
|
|
59
|
-
# Links
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
edges = operands.combination(2).select { |operand, other| linked?(operand.source.table, other.source.table) }
|
|
64
|
-
visited = [operands.first]
|
|
65
|
-
root = attach(operand: operands.first, node: operands.first, edges:, visited:)
|
|
66
|
-
check_chain!(operands:, visited:, edges:)
|
|
67
|
-
|
|
68
|
-
root
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Nests each unvisited operand linked to `operand`. `node` is the operand with its key and link set.
|
|
72
|
-
def attach(operand:, node:, edges:, visited:)
|
|
73
|
-
neighbours = edges.filter_map { |pair| (pair - [operand]).first if pair.include?(operand) } - visited
|
|
74
|
-
visited.concat(neighbours)
|
|
75
|
-
|
|
76
|
-
children = neighbours.map do |child|
|
|
77
|
-
key, link = link_to(parent: node.source.table, child: child.source.table, key: nil)
|
|
78
|
-
attach(operand: child, node: child.with(key:, link:), edges:, visited:)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
with_children(node:, children:)
|
|
63
|
+
# Links each entry's children to it, from the leaves up.
|
|
64
|
+
def assemble(entry:)
|
|
65
|
+
children = entry.children.map { |child| linked(parent: entry.node, child: assemble(entry: child), key: nil) }
|
|
66
|
+
with_children(node: entry.node, children:)
|
|
82
67
|
end
|
|
83
68
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
node.
|
|
69
|
+
# Tables in brackets nest directly under the table whose brackets they're in.
|
|
70
|
+
def bracketed(source:)
|
|
71
|
+
node = Nesting::Node.new(source:, key: nil, link: nil, children: [])
|
|
72
|
+
with_children(node:, children: source.nested.map { |nested| linked(parent: node, child: build(node: nested.node), key: nested.key) })
|
|
88
73
|
end
|
|
89
74
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
75
|
+
# Sets a child's key and link to its parent node. A join links through its table written next to the +.
|
|
76
|
+
def linked(parent:, child:, key:)
|
|
77
|
+
named = parent.foreign_keys.merge(child.foreign_keys)
|
|
78
|
+
table = parent.join&.linked_table(others: child.tables, schema: @schema, foreign_keys: named) || parent.source.table
|
|
79
|
+
joined = child.join&.link(parent: table, key:, tree: self, schema: @schema, foreign_keys: named)
|
|
80
|
+
source, key, link = joined || [child.source, *link_to(parent: table, child: child.source.table, key:, named:)]
|
|
81
|
+
child.with(source:, key:, link: link.with(parent_table: parent.join && table))
|
|
94
82
|
end
|
|
95
83
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
unreached = (operands - visited).map { |operand| operand.source.table }
|
|
99
|
-
raise JoinError, "No foreign key links #{unreached.join(' and ')} to #{(tables - unreached).join(', ')}" if unreached.any?
|
|
100
|
-
raise JoinError, format(MULTIPLE_PATHS_MESSAGE, tables: tables.join(', ')) if edges.length > operands.length - 1
|
|
84
|
+
def with_children(node:, children:)
|
|
85
|
+
node.with(children: node.children + children).tap { |linked| check_keys!(node: linked) }
|
|
101
86
|
end
|
|
102
87
|
|
|
103
|
-
|
|
104
|
-
|
|
88
|
+
# Each nested table needs its own key, which a renamed column can't also use.
|
|
89
|
+
def check_keys!(node:)
|
|
90
|
+
keys = node.children.map(&:key)
|
|
105
91
|
duplicate = keys.find { |key| keys.count(key) > 1 }
|
|
106
|
-
raise JoinError, "Two tables nest in #{
|
|
107
|
-
end
|
|
92
|
+
raise JoinError, "Two tables nest in #{node.tables.join(', ')} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
|
|
94
|
+
renamed = (keys & (node.join ? [] : node.source.renames.keys)).first
|
|
95
|
+
raise JoinError, "#{node.tables.join(', ')} returns #{renamed} as both a renamed column and a nested table. Rename one" if renamed
|
|
111
96
|
end
|
|
112
97
|
|
|
113
98
|
def named_link(links:, key:)
|
data/lib/order_compiler.rb
CHANGED
|
@@ -18,10 +18,12 @@ class LowData
|
|
|
18
18
|
dataset.limit(rows.limit, rows.offset.zero? ? nil : rows.offset)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Rows without a value sort last in both directions, so `-Col[:views] * 10` is the 10 most viewed on every database.
|
|
22
|
+
# Postgres puts NULLs last ascending and first descending, and SQLite and MySQL the reverse.
|
|
21
23
|
def order(keys:, tables:)
|
|
22
24
|
keys.map do |key|
|
|
23
25
|
column = Sequel[owner(column: key.column, tables:)][key.column]
|
|
24
|
-
key.descending ? column.desc : column
|
|
26
|
+
key.descending ? column.desc(nulls: :last) : column.asc(nulls: :last)
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
|
data/lib/query_compiler.rb
CHANGED
|
@@ -4,8 +4,10 @@ class LowData
|
|
|
4
4
|
# Compiles a DataExpression join tree into a Sequel dataset, inferring each join's ON clause from foreign keys.
|
|
5
5
|
class QueryCompiler
|
|
6
6
|
INVERSION_JOIN_MESSAGE = "Table[] can't be joined with other expressions"
|
|
7
|
-
NESTING_JOIN_MESSAGE = 'Nested tables return nested data, so + and tables nested in brackets can\'t be combined with &, <, >
|
|
8
|
-
|
|
7
|
+
NESTING_JOIN_MESSAGE = 'Nested tables return nested data, so + and tables nested in brackets can\'t be combined with &, <, > ' \
|
|
8
|
+
'or <=>. Nest the join instead, e.g. Users[:username] + (Posts[:title] & Comments[:body])'
|
|
9
|
+
FULL_JOIN_MESSAGE = 'MySQL has no FULL JOIN, so <=> only runs on other databases. Use < or > for a left or right join'
|
|
10
|
+
ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users < Posts) ^ Col[:username]. Ruby binds ^ tighter ' \
|
|
9
11
|
'than <, > and <=>, and * and / tighter than every join, so add parentheses around the join'
|
|
10
12
|
|
|
11
13
|
def initialize(database:)
|
|
@@ -17,26 +19,37 @@ class LowData
|
|
|
17
19
|
@orders = OrderCompiler.new(schema: @schema)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# `links` are columns a nested join selects to link its rows, as well as the columns asked for. See Nesting.
|
|
23
|
+
def compile(node:, links: [])
|
|
24
|
+
return compile_ordered(node:, links:) if node.is_a?(DataExpression::Ordered)
|
|
22
25
|
|
|
23
|
-
compile_unordered(node:, sources: [])
|
|
26
|
+
compile_unordered(node:, sources: [], links:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Empty brackets select no columns, so a query of only those has nothing to return.
|
|
30
|
+
def self.no_columns_message(table:)
|
|
31
|
+
name = LowData.constant_name(table)
|
|
32
|
+
"Empty brackets select no columns, so #{name}[] returns nothing. Write #{name} for every column, or name them, " \
|
|
33
|
+
"e.g. #{name}[:id]"
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
private
|
|
27
37
|
|
|
28
38
|
# Sort keys and rows apply to the whole query, so each sort column can belong to any joined table.
|
|
29
|
-
def compile_ordered(node:)
|
|
39
|
+
def compile_ordered(node:, links:)
|
|
30
40
|
sources = []
|
|
31
|
-
dataset = compile_unordered(node: node.node, sources:)
|
|
41
|
+
dataset = compile_unordered(node: node.node, sources:, links:)
|
|
32
42
|
@orders.apply(dataset:, keys: node.keys, rows: node.rows, tables: sources.map(&:table))
|
|
33
43
|
end
|
|
34
44
|
|
|
35
|
-
def compile_unordered(node:, sources:)
|
|
45
|
+
def compile_unordered(node:, sources:, links:)
|
|
36
46
|
return compile_inversion(node:, sources:) if node.is_a?(DataExpression::Inversion)
|
|
37
47
|
|
|
38
48
|
dataset = filter(dataset: build(node:, sources:), sources:)
|
|
39
|
-
|
|
49
|
+
selections = sources.flat_map { |source| selections(source:) } + links
|
|
50
|
+
raise ArgumentError, QueryCompiler.no_columns_message(table: sources.first.table) if selections.empty?
|
|
51
|
+
|
|
52
|
+
dataset.select(*selections)
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
def compile_inversion(node:, sources:)
|
|
@@ -56,18 +69,22 @@ class LowData
|
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
def join_dataset(dataset:, type:, right:, sources:)
|
|
72
|
+
check_join!(type:, right:, sources:)
|
|
73
|
+
conditions = join_conditions(left: sources, right:)
|
|
74
|
+
sources << right
|
|
75
|
+
dataset.join_table(type, right.table, conditions)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_join!(type:, right:, sources:)
|
|
59
79
|
raise JoinError, INVERSION_JOIN_MESSAGE if right.is_a?(DataExpression::Inversion)
|
|
60
80
|
raise JoinError, ORDERED_JOIN_MESSAGE if right.is_a?(DataExpression::Ordered)
|
|
61
81
|
raise JoinError, NESTING_JOIN_MESSAGE if right.nested.any?
|
|
62
82
|
raise JoinError, "Can't join #{right.table} to itself" if sources.any? { |source| source.table == right.table }
|
|
63
|
-
|
|
64
|
-
conditions = join_conditions(left_tables: sources.map(&:table), right_table: right.table)
|
|
65
|
-
sources << right
|
|
66
|
-
dataset.join_table(type, right.table, conditions)
|
|
83
|
+
raise JoinError, FULL_JOIN_MESSAGE if type == :full && @database.database_type == :mysql
|
|
67
84
|
end
|
|
68
85
|
|
|
69
86
|
def nesting?(node:)
|
|
70
|
-
node.is_a?(DataExpression::Join) && node.
|
|
87
|
+
node.is_a?(DataExpression::Join) && node.nest?
|
|
71
88
|
end
|
|
72
89
|
|
|
73
90
|
def source_dataset(source:, sources:)
|
|
@@ -82,26 +99,42 @@ class LowData
|
|
|
82
99
|
sources.reduce(dataset) { |filtered, source| @filters.apply(dataset: filtered, source:) }
|
|
83
100
|
end
|
|
84
101
|
|
|
102
|
+
# A bare table selects every column, and empty brackets select none.
|
|
85
103
|
def selections(source:)
|
|
86
|
-
return [Sequel[source.table].*] if source.
|
|
104
|
+
return [Sequel[source.table].*] if source.every_column?
|
|
87
105
|
|
|
88
|
-
source.
|
|
106
|
+
@schema.check_renames!(table: source.table, renames: source.renames)
|
|
107
|
+
source.columns.map { |column| Sequel[source.table][column] } + source.aliases
|
|
89
108
|
end
|
|
90
109
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
raise JoinError,
|
|
97
|
-
raise JoinError, "More than one foreign key links #{
|
|
110
|
+
# Each table joins to the table before it, or to an earlier one by a foreign key named in round brackets, so foreign
|
|
111
|
+
# keys between other tables can't change or break a join.
|
|
112
|
+
def join_conditions(left:, right:)
|
|
113
|
+
named = left.flat_map { |source| references(from: right, to: source, named: true) + references(from: source, to: right, named: true) }
|
|
114
|
+
candidates = named.any? ? named : references(from: right, to: left.last) + references(from: left.last, to: right)
|
|
115
|
+
raise JoinError, unlinked_join_message(left:, right:) if candidates.empty?
|
|
116
|
+
raise JoinError, "More than one foreign key links #{right.table} to #{left.last.table}" if candidates.length > 1
|
|
98
117
|
|
|
99
118
|
candidates.first
|
|
100
119
|
end
|
|
101
120
|
|
|
121
|
+
def references(from:, to:, named: false)
|
|
122
|
+
@schema.references(from: from.table, to: to.table, named: named ? from.filter.foreign_keys : nil)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def unlinked_join_message(left:, right:)
|
|
126
|
+
message = "No foreign key links #{right.table} to #{left.last.table}, the table before it"
|
|
127
|
+
choices = left[0...-1].flat_map do |source|
|
|
128
|
+
[[right.table, source.table], [source.table, right.table]].flat_map do |from, to|
|
|
129
|
+
@schema.links(from:, to:).map { |link| "#{LowData.constant_name(from)}(:#{link[:columns].first})" }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
choices.empty? ? message : "#{message}. To join it to an earlier table, name the foreign key: #{choices.sort.join(' or ')}"
|
|
133
|
+
end
|
|
134
|
+
|
|
102
135
|
def nested_join_message
|
|
103
|
-
'Ruby evaluated a join on the right first, e.g. `Users
|
|
104
|
-
'because & binds tighter than <. Add parentheses to join from the left: `(Users
|
|
136
|
+
'Ruby evaluated a join on the right first, e.g. `Users < Posts & Comments` runs `Posts & Comments` first ' \
|
|
137
|
+
'because & binds tighter than <. Add parentheses to join from the left: `(Users < Posts) & Comments`'
|
|
105
138
|
end
|
|
106
139
|
end
|
|
107
140
|
end
|
data/lib/rows.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
class LowData
|
|
4
4
|
# Which sorted rows to keep. Rows count from 0, as in Ruby:
|
|
5
|
-
# Posts
|
|
6
|
-
# (Posts
|
|
7
|
-
# Posts
|
|
5
|
+
# Posts * 10 # LIMIT 10
|
|
6
|
+
# (Posts / 20)[2] # LIMIT 20 OFFSET 40, the third page of 20
|
|
7
|
+
# Posts ^ Col[:id][20...40] # LIMIT 20 OFFSET 20
|
|
8
8
|
# Pages have a `page_size` until a page is picked, and a `limit` and `offset` after.
|
|
9
9
|
Rows = ::Data.define(:offset, :limit, :page_size) do
|
|
10
10
|
def self.limit(count)
|
data/lib/schema.rb
CHANGED
|
@@ -28,16 +28,33 @@ class LowData
|
|
|
28
28
|
foreign_keys(table: other).any? { |foreign_key| foreign_key[:table] == table }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Sorted, because databases list their tables in different orders, and error messages name the first.
|
|
32
|
+
((outgoing + incoming).uniq - [table]).sort
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
# Foreign keys on the `from` table that reference the `to` table, as join conditions.
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# Foreign keys on the `from` table that reference the `to` table, as join conditions. `named` keeps only the foreign
|
|
36
|
+
# keys with those columns.
|
|
37
|
+
def references(from:, to:, named: nil)
|
|
38
|
+
links(from:, to:).select { |link| named.nil? || (link[:columns] & named).any? }.map do |link|
|
|
37
39
|
link[:columns].zip(link[:keys]).to_h { |column, key| [Sequel[from][column], Sequel[to][key]] }
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# A renamed column must exist and its new name mustn't, so a rename written backwards, `title: :headline`, raises.
|
|
44
|
+
def check_renames!(table:, renames:)
|
|
45
|
+
columns = columns(table:)
|
|
46
|
+
|
|
47
|
+
renames.each do |name, column|
|
|
48
|
+
raise ArgumentError, backwards_rename_message(table:, name:, column:) if columns.include?(name) && !columns.include?(column)
|
|
49
|
+
raise ArgumentError, "#{table} has no #{column} column to rename" unless columns.include?(column)
|
|
50
|
+
raise ArgumentError, "#{table} already has a #{name} column, so #{column} can't be renamed to it" if columns.include?(name)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def linked?(table, other)
|
|
55
|
+
links(from: table, to: other).any? || links(from: other, to: table).any?
|
|
56
|
+
end
|
|
57
|
+
|
|
41
58
|
# Foreign keys on the `from` table that reference the `to` table, as their columns and the keys they reference.
|
|
42
59
|
def links(from:, to:)
|
|
43
60
|
foreign_keys(table: from).select { |foreign_key| foreign_key[:table] == to }.map do |foreign_key|
|
|
@@ -47,6 +64,10 @@ class LowData
|
|
|
47
64
|
|
|
48
65
|
private
|
|
49
66
|
|
|
67
|
+
def backwards_rename_message(table:, name:, column:)
|
|
68
|
+
"#{table} has no #{column} column. To rename #{name}, write #{column}: :#{name}"
|
|
69
|
+
end
|
|
70
|
+
|
|
50
71
|
def schema(table:)
|
|
51
72
|
@schemas[table] ||= @database.schema(table)
|
|
52
73
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
class TableReference
|
|
5
|
+
# Writes run straight away. See Writer:
|
|
6
|
+
# Posts << { title: 'Hello' } # Inserts, returning the new row's primary key.
|
|
7
|
+
# Posts(slug: 'hello') <= { title: 'Hello' } # Upserts, matching on the round brackets.
|
|
8
|
+
# Posts(id: 1)[:title] = 'New' # Updates the rows chosen by the round brackets.
|
|
9
|
+
# Posts(id: 1).delete # Deletes them, returning how many.
|
|
10
|
+
module Writes
|
|
11
|
+
def <<(rows)
|
|
12
|
+
writer.insert(source: write_source, rows:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def <=(rows)
|
|
16
|
+
writer.upsert(source: write_source, rows:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Ruby makes an assignment return its right-hand side, so `Posts(id: 1)[:title] = 'New'` returns 'New'.
|
|
20
|
+
def []=(*columns, value)
|
|
21
|
+
writer.update(source: write_source, values: assignments(columns:, value:))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delete
|
|
25
|
+
writer.delete(source: write_source)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def writer
|
|
31
|
+
Writer.new(database: LowData.database)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write_source
|
|
35
|
+
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't be written to" if negated
|
|
36
|
+
|
|
37
|
+
source
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The columns in square brackets and the value assigned to them:
|
|
41
|
+
# Posts(id: 1)[:title] = 'New' # One column takes a value.
|
|
42
|
+
# Posts(id: 1)[:title, :body] = ['New', '...'] # Several columns take an array of as many values.
|
|
43
|
+
# Posts(id: 1)[] = { title: 'New' } # Empty brackets take a hash of columns and values.
|
|
44
|
+
def assignments(columns:, value:)
|
|
45
|
+
check_columns!(columns:)
|
|
46
|
+
return assigned_hash(value:) if columns.empty?
|
|
47
|
+
return { columns.first => assigned_value(value:) } if columns.one?
|
|
48
|
+
return columns.zip(value).to_h if value.is_a?(Array) && value.length == columns.length
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "#{columns.length} columns take #{columns.length} values, e.g. Posts(id: 1)[:title, :body] = ['New', '...']"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sequel would write a hash into one column as a condition, storing 0 or 1, so a hash for one column raises.
|
|
54
|
+
def assigned_value(value:)
|
|
55
|
+
return value unless value.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, 'One column takes one value, not a hash. To set the columns in a hash, use empty brackets, e.g. ' \
|
|
58
|
+
"Posts(id: 1)[] = { title: 'New' }"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assigned_hash(value:)
|
|
62
|
+
return value if value.is_a?(Hash) && value.any?
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "Empty brackets take a hash of columns and values, e.g. Posts(id: 1)[] = { title: 'New' }"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
include Writes
|
|
69
|
+
end
|
|
70
|
+
end
|