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 +4 -4
- data/config/rubocop.yml +14 -0
- data/lib/data_expression.rb +43 -12
- data/lib/expression_writes.rb +45 -0
- data/lib/filter_compiler.rb +12 -0
- data/lib/inversion.rb +42 -11
- data/lib/lowdata.rb +4 -0
- data/lib/nested_join.rb +82 -0
- data/lib/nesting.rb +82 -19
- data/lib/nesting_path.rb +108 -0
- data/lib/nesting_tree.rb +49 -72
- data/lib/query_compiler.rb +28 -12
- data/lib/schema.rb +23 -3
- data/lib/table_reference.rb +72 -9
- data/lib/version.rb +1 -1
- data/lib/write_checks.rb +65 -0
- data/lib/writer.rb +143 -0
- metadata +9 -3
data/lib/nesting_tree.rb
CHANGED
|
@@ -1,113 +1,90 @@
|
|
|
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
|
+
# An expression's node, waiting for the children NestingPath nests in it.
|
|
17
|
+
def entry(node:)
|
|
16
18
|
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)
|
|
19
|
+
return ordered(node:) if node.is_a?(DataExpression::Ordered)
|
|
20
|
+
return NestingPath::Entry.for(bracketed(source: node)) if node.is_a?(DataExpression::Source)
|
|
21
|
+
return NestingPath::Entry.for(Nesting::Node.new(source: NestedJoin.build(node:), key: nil, link: nil, children: [])) unless node.nest?
|
|
19
22
|
|
|
20
|
-
chain(
|
|
23
|
+
@path.chain(segments: NestingPath.segments(node:))
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
# 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
|
-
|
|
27
|
+
# so `author:` follows `author_id`, and a foreign key column named in round brackets picks between several.
|
|
28
|
+
# FilterCompiler links related tables the same way.
|
|
29
|
+
def link_to(parent:, child:, key:, named: {})
|
|
26
30
|
to_one = @schema.links(from: parent, to: child)
|
|
27
|
-
|
|
28
|
-
return one(parent:, child:, link:
|
|
31
|
+
named_link = named_link(links: to_one, key:)
|
|
32
|
+
return one(parent:, child:, link: named_link, key:) if named_link
|
|
29
33
|
|
|
30
|
-
links = to_one + @schema.links(from: child, to: parent)
|
|
34
|
+
links = picked(links: to_one + @schema.links(from: child, to: parent), named:)
|
|
31
35
|
raise JoinError, "No foreign key links #{child} to #{parent}" if links.empty?
|
|
32
36
|
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.
|
|
37
|
+
return one(parent:, child:, link: links.first, key:) if to_one.include?(links.first)
|
|
34
38
|
|
|
35
39
|
many(parent:, child:, link: links.first, key:)
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
private
|
|
39
43
|
|
|
40
|
-
# A
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
raise JoinError, QueryCompiler::NESTING_JOIN_MESSAGE unless node.type == :nest
|
|
44
|
-
|
|
45
|
-
operands(node: node.left) + [node.right]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Tables in brackets nest directly under the table whose brackets they're in.
|
|
49
|
-
def bracketed(source:)
|
|
50
|
-
children = source.nested.map do |nested|
|
|
51
|
-
child = build(node: nested.node)
|
|
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:)
|
|
44
|
+
# A sort or limit applies to the first table of what it sorts.
|
|
45
|
+
def ordered(node:)
|
|
46
|
+
entry(node: node.node).tap { |entry| entry.node = entry.node.with(keys: node.keys, rows: node.rows) }
|
|
57
47
|
end
|
|
58
48
|
|
|
59
|
-
#
|
|
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
|
|
49
|
+
# The links whose foreign key is named in round brackets, or all of them if none is.
|
|
50
|
+
def picked(links:, named:)
|
|
51
|
+
picked = links.select { |link| named.values.flatten.include?(link[:columns].first) }
|
|
52
|
+
picked.any? ? picked : links
|
|
69
53
|
end
|
|
70
54
|
|
|
71
|
-
#
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
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:)
|
|
55
|
+
# Links each entry's children to it, from the leaves up.
|
|
56
|
+
def assemble(entry:)
|
|
57
|
+
children = entry.children.map { |child| linked(parent: entry.node, child: assemble(entry: child), key: nil) }
|
|
58
|
+
with_children(node: entry.node, children:)
|
|
82
59
|
end
|
|
83
60
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
node.
|
|
61
|
+
# Tables in brackets nest directly under the table whose brackets they're in.
|
|
62
|
+
def bracketed(source:)
|
|
63
|
+
node = Nesting::Node.new(source:, key: nil, link: nil, children: [])
|
|
64
|
+
with_children(node:, children: source.nested.map { |nested| linked(parent: node, child: build(node: nested.node), key: nested.key) })
|
|
88
65
|
end
|
|
89
66
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
67
|
+
# Sets a child's key and link to its parent node. A join links through its table written next to the +.
|
|
68
|
+
def linked(parent:, child:, key:)
|
|
69
|
+
named = parent.foreign_keys.merge(child.foreign_keys)
|
|
70
|
+
table = parent.join&.linked_table(others: child.tables, schema: @schema, foreign_keys: named) || parent.source.table
|
|
71
|
+
joined = child.join&.link(parent: table, key:, tree: self, schema: @schema, foreign_keys: named)
|
|
72
|
+
source, key, link = joined || [child.source, *link_to(parent: table, child: child.source.table, key:, named:)]
|
|
73
|
+
child.with(source:, key:, link: link.with(parent_table: parent.join && table))
|
|
94
74
|
end
|
|
95
75
|
|
|
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
|
|
76
|
+
def with_children(node:, children:)
|
|
77
|
+
node.with(children: node.children + children).tap { |linked| check_keys!(node: linked) }
|
|
101
78
|
end
|
|
102
79
|
|
|
103
|
-
|
|
104
|
-
|
|
80
|
+
# Each nested table needs its own key, which a renamed column can't also use.
|
|
81
|
+
def check_keys!(node:)
|
|
82
|
+
keys = node.children.map(&:key)
|
|
105
83
|
duplicate = keys.find { |key| keys.count(key) > 1 }
|
|
106
|
-
raise JoinError, "Two tables nest in #{
|
|
107
|
-
end
|
|
84
|
+
raise JoinError, "Two tables nest in #{node.tables.join(', ')} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
|
|
108
85
|
|
|
109
|
-
|
|
110
|
-
|
|
86
|
+
renamed = (keys & (node.join ? [] : node.source.renames.keys)).first
|
|
87
|
+
raise JoinError, "#{node.tables.join(', ')} returns #{renamed} as both a renamed column and a nested table. Rename one" if renamed
|
|
111
88
|
end
|
|
112
89
|
|
|
113
90
|
def named_link(links:, key:)
|
data/lib/query_compiler.rb
CHANGED
|
@@ -4,7 +4,8 @@ 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 &, <, >
|
|
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])'
|
|
8
9
|
ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users[] < Posts[]) ^ Col[:username]. Ruby binds ^ tighter ' \
|
|
9
10
|
'than <, > and <=>, and * and / tighter than every join, so add parentheses around the join'
|
|
10
11
|
|
|
@@ -61,13 +62,13 @@ class LowData
|
|
|
61
62
|
raise JoinError, NESTING_JOIN_MESSAGE if right.nested.any?
|
|
62
63
|
raise JoinError, "Can't join #{right.table} to itself" if sources.any? { |source| source.table == right.table }
|
|
63
64
|
|
|
64
|
-
conditions = join_conditions(
|
|
65
|
+
conditions = join_conditions(left: sources, right:)
|
|
65
66
|
sources << right
|
|
66
67
|
dataset.join_table(type, right.table, conditions)
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def nesting?(node:)
|
|
70
|
-
node.is_a?(DataExpression::Join) && node.
|
|
71
|
+
node.is_a?(DataExpression::Join) && node.nest?
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def source_dataset(source:, sources:)
|
|
@@ -83,22 +84,37 @@ class LowData
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def selections(source:)
|
|
86
|
-
return [Sequel[source.table].*] if source.
|
|
87
|
+
return [Sequel[source.table].*] if source.every_column?
|
|
87
88
|
|
|
88
|
-
source.
|
|
89
|
+
@schema.check_renames!(table: source.table, renames: source.renames)
|
|
90
|
+
source.columns.map { |column| Sequel[source.table][column] } + source.aliases
|
|
89
91
|
end
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
raise JoinError,
|
|
97
|
-
raise JoinError, "More than one foreign key links #{
|
|
93
|
+
# Each table joins to the table before it, or to an earlier one by a foreign key named in round brackets, so foreign
|
|
94
|
+
# keys between other tables can't change or break a join.
|
|
95
|
+
def join_conditions(left:, right:)
|
|
96
|
+
named = left.flat_map { |source| references(from: right, to: source, named: true) + references(from: source, to: right, named: true) }
|
|
97
|
+
candidates = named.any? ? named : references(from: right, to: left.last) + references(from: left.last, to: right)
|
|
98
|
+
raise JoinError, unlinked_join_message(left:, right:) if candidates.empty?
|
|
99
|
+
raise JoinError, "More than one foreign key links #{right.table} to #{left.last.table}" if candidates.length > 1
|
|
98
100
|
|
|
99
101
|
candidates.first
|
|
100
102
|
end
|
|
101
103
|
|
|
104
|
+
def references(from:, to:, named: false)
|
|
105
|
+
@schema.references(from: from.table, to: to.table, named: named ? from.filter.foreign_keys : nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unlinked_join_message(left:, right:)
|
|
109
|
+
message = "No foreign key links #{right.table} to #{left.last.table}, the table before it"
|
|
110
|
+
choices = left[0...-1].flat_map do |source|
|
|
111
|
+
[[right.table, source.table], [source.table, right.table]].flat_map do |from, to|
|
|
112
|
+
@schema.links(from:, to:).map { |link| "#{LowData.constant_name(from)}(:#{link[:columns].first})" }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
choices.empty? ? message : "#{message}. To join it to an earlier table, name the foreign key: #{choices.sort.join(' or ')}"
|
|
116
|
+
end
|
|
117
|
+
|
|
102
118
|
def nested_join_message
|
|
103
119
|
'Ruby evaluated a join on the right first, e.g. `Users[] < Posts[] & Comments[]` runs `Posts[] & Comments[]` first ' \
|
|
104
120
|
'because & binds tighter than <. Add parentheses to join from the left: `(Users[] < Posts[]) & Comments[]`'
|
data/lib/schema.rb
CHANGED
|
@@ -31,13 +31,29 @@ class LowData
|
|
|
31
31
|
(outgoing + incoming).uniq - [table]
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Foreign keys on the `from` table that reference the `to` table, as join conditions.
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
# Foreign keys on the `from` table that reference the `to` table, as join conditions. `named` keeps only the foreign
|
|
35
|
+
# keys with those columns.
|
|
36
|
+
def references(from:, to:, named: nil)
|
|
37
|
+
links(from:, to:).select { |link| named.nil? || (link[:columns] & named).any? }.map do |link|
|
|
37
38
|
link[:columns].zip(link[:keys]).to_h { |column, key| [Sequel[from][column], Sequel[to][key]] }
|
|
38
39
|
end
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# A renamed column must exist and its new name mustn't, so a rename written backwards, `title: :headline`, raises.
|
|
43
|
+
def check_renames!(table:, renames:)
|
|
44
|
+
columns = columns(table:)
|
|
45
|
+
|
|
46
|
+
renames.each do |name, column|
|
|
47
|
+
raise ArgumentError, backwards_rename_message(table:, name:, column:) if columns.include?(name) && !columns.include?(column)
|
|
48
|
+
raise ArgumentError, "#{table} has no #{column} column to rename" unless columns.include?(column)
|
|
49
|
+
raise ArgumentError, "#{table} already has a #{name} column, so #{column} can't be renamed to it" if columns.include?(name)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def linked?(table, other)
|
|
54
|
+
links(from: table, to: other).any? || links(from: other, to: table).any?
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
# Foreign keys on the `from` table that reference the `to` table, as their columns and the keys they reference.
|
|
42
58
|
def links(from:, to:)
|
|
43
59
|
foreign_keys(table: from).select { |foreign_key| foreign_key[:table] == to }.map do |foreign_key|
|
|
@@ -47,6 +63,10 @@ class LowData
|
|
|
47
63
|
|
|
48
64
|
private
|
|
49
65
|
|
|
66
|
+
def backwards_rename_message(table:, name:, column:)
|
|
67
|
+
"#{table} has no #{column} column. To rename #{name}, write #{column}: :#{name}"
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
def schema(table:)
|
|
51
71
|
@schemas[table] ||= @database.schema(table)
|
|
52
72
|
end
|
data/lib/table_reference.rb
CHANGED
|
@@ -20,15 +20,17 @@ class LowData
|
|
|
20
20
|
# Posts[:title] # A symbol is a column. No columns selects every column.
|
|
21
21
|
# Users[:username, Posts[:title]] # A data expression is a nested table.
|
|
22
22
|
# Reviews[:rating, author: Users[:username]] # A keyword with a data expression names a nested table.
|
|
23
|
+
# Posts[:id, headline: :title] # A keyword with a symbol renames a column.
|
|
23
24
|
def [](*arguments, **keywords)
|
|
24
25
|
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't select columns" if negated
|
|
25
26
|
return inverted(arguments:, keywords:) if @inverted
|
|
26
27
|
|
|
27
28
|
expressions, columns = arguments.partition { |argument| argument.is_a?(DataExpression) }
|
|
28
29
|
check_columns!(columns:)
|
|
29
|
-
|
|
30
|
+
named, renames = named(keywords:, columns:)
|
|
31
|
+
nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named)
|
|
30
32
|
|
|
31
|
-
DataExpression.new(node: DataExpression::Source.new(table:, columns:, nested:, filter:))
|
|
33
|
+
DataExpression.new(node: DataExpression::Source.new(table:, columns:, renames:, nested:, filter:))
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# Each argument's type decides its meaning:
|
|
@@ -37,8 +39,9 @@ class LowData
|
|
|
37
39
|
# Users(Posts), Users(~Posts) # A table keeps rows with, or without, related rows.
|
|
38
40
|
# Users(Posts => 3..) # A table as a key keeps rows with that many related rows.
|
|
39
41
|
# Reviews(author: Users(username: 'alice')) # A keyword with a table names the foreign key to follow.
|
|
42
|
+
# Posts(:user_id) # A symbol names the foreign key a nested join links by.
|
|
40
43
|
def where(*arguments, **keywords, &block)
|
|
41
|
-
related = arguments.map { |argument| related(key: nil, reference: argument) }
|
|
44
|
+
related = arguments.grep_v(Symbol).map { |argument| related(key: nil, reference: argument) }
|
|
42
45
|
values = {}
|
|
43
46
|
|
|
44
47
|
keywords.each do |key, value|
|
|
@@ -49,7 +52,7 @@ class LowData
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
conditions = block ? [Sequel.virtual_row(&block)] : []
|
|
52
|
-
with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:)))
|
|
55
|
+
with(filter: filter.merge(DataExpression::Filter.new(values:, conditions:, related:, foreign_keys: arguments.grep(Symbol))))
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# `~Posts` keeps rows without related posts.
|
|
@@ -57,6 +60,28 @@ class LowData
|
|
|
57
60
|
with(negated: !negated)
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
# Writes run straight away. See Writer:
|
|
64
|
+
# Posts << { title: 'Hello' } # Inserts, returning the new row's primary key.
|
|
65
|
+
# Posts(slug: 'hello') <= { title: 'Hello' } # Upserts, matching on the round brackets.
|
|
66
|
+
# Posts(id: 1)[:title] = 'New' # Updates the rows chosen by the round brackets.
|
|
67
|
+
# Posts(id: 1).delete # Deletes them, returning how many.
|
|
68
|
+
def <<(rows)
|
|
69
|
+
writer.insert(source: write_source, rows:)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def <=(rows)
|
|
73
|
+
writer.upsert(source: write_source, rows:)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Ruby makes an assignment return its right-hand side, so `Posts(id: 1)[:title] = 'New'` returns 'New'.
|
|
77
|
+
def []=(*columns, value)
|
|
78
|
+
writer.update(source: write_source, values: assignments(columns:, value:))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete
|
|
82
|
+
writer.delete(source: write_source)
|
|
83
|
+
end
|
|
84
|
+
|
|
60
85
|
def source
|
|
61
86
|
DataExpression::Source.new(table:, columns: [], filter:)
|
|
62
87
|
end
|
|
@@ -67,6 +92,35 @@ class LowData
|
|
|
67
92
|
self.class.new(table:, inverted: @inverted, filter:, negated:)
|
|
68
93
|
end
|
|
69
94
|
|
|
95
|
+
def writer
|
|
96
|
+
Writer.new(database: LowData.database)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_source
|
|
100
|
+
raise ArgumentError, "~#{LowData.constant_name(table)} filters by related rows, so it can't be written to" if negated
|
|
101
|
+
|
|
102
|
+
source
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The columns in square brackets and the value assigned to them:
|
|
106
|
+
# Posts(id: 1)[:title] = 'New' # One column takes a value.
|
|
107
|
+
# Posts(id: 1)[:title, :body] = ['New', '...'] # Several columns take an array of as many values.
|
|
108
|
+
# Posts(id: 1)[] = { title: 'New' } # Empty brackets take a hash of columns and values.
|
|
109
|
+
def assignments(columns:, value:)
|
|
110
|
+
check_columns!(columns:)
|
|
111
|
+
return assigned_hash(value:) if columns.empty?
|
|
112
|
+
return { columns.first => value } if columns.one?
|
|
113
|
+
return columns.zip(value).to_h if value.is_a?(Array) && value.length == columns.length
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "#{columns.length} columns take #{columns.length} values, e.g. Posts(id: 1)[:title, :body] = ['New', '...']"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def assigned_hash(value:)
|
|
119
|
+
return value if value.is_a?(Hash) && value.any?
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "Empty brackets take a hash of columns and values, e.g. Posts(id: 1)[] = { title: 'New' }"
|
|
122
|
+
end
|
|
123
|
+
|
|
70
124
|
def related(key:, reference:, count: nil)
|
|
71
125
|
unless reference.is_a?(TableReference)
|
|
72
126
|
raise ArgumentError, "Round brackets take filters and tables, e.g. Posts(published: true) or Users(Posts), not #{reference.inspect}"
|
|
@@ -76,11 +130,19 @@ class LowData
|
|
|
76
130
|
DataExpression::Related.new(key:, source: reference.source, count: count || (reference.negated ? 0 : 1..))
|
|
77
131
|
end
|
|
78
132
|
|
|
79
|
-
|
|
80
|
-
|
|
133
|
+
# Returns the named nested tables and the renamed columns.
|
|
134
|
+
def named(keywords:, columns:)
|
|
135
|
+
named, others = keywords.partition { |_key, value| value.is_a?(DataExpression) }
|
|
136
|
+
renames, filters = others.partition { |_key, value| value.is_a?(Symbol) }
|
|
81
137
|
raise ArgumentError, format(FILTER_KEYWORD_MESSAGE, keywords: filters.to_h) if filters.any?
|
|
82
138
|
|
|
83
|
-
named
|
|
139
|
+
check_renames!(names: columns + named.map(&:first), renames: renames.to_h)
|
|
140
|
+
[named, renames.to_h]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def check_renames!(names:, renames:)
|
|
144
|
+
duplicate = (renames.keys & names).first
|
|
145
|
+
raise ArgumentError, "#{LowData.constant_name(table)}[] returns #{duplicate} twice. Name each value once" if duplicate
|
|
84
146
|
end
|
|
85
147
|
|
|
86
148
|
def nested(pairs:)
|
|
@@ -89,9 +151,10 @@ class LowData
|
|
|
89
151
|
|
|
90
152
|
def inverted(arguments:, keywords:)
|
|
91
153
|
check_columns!(columns: arguments)
|
|
92
|
-
raise ArgumentError, 'Table[] takes column names only'
|
|
154
|
+
raise ArgumentError, 'Table[] takes column names only, and renames such as headline: :title' unless keywords.values.all?(Symbol)
|
|
93
155
|
|
|
94
|
-
|
|
156
|
+
check_renames!(names: arguments, renames: keywords)
|
|
157
|
+
DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, renames: keywords, filter:))
|
|
95
158
|
end
|
|
96
159
|
|
|
97
160
|
def check_columns!(columns:)
|
data/lib/version.rb
CHANGED
data/lib/write_checks.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LowData
|
|
4
|
+
# Checks a write before it runs, so a mistake raises rather than writing the wrong rows. See Writer.
|
|
5
|
+
module WriteChecks
|
|
6
|
+
ROWS_MESSAGE = '%<operator>s takes a hash or an array of hashes, e.g. Posts %<operator>s { title: \'Hello\' }, not %<rows>s'
|
|
7
|
+
INSERT_FILTER_MESSAGE = 'An insert adds new rows, so it takes no round brackets. To insert a row or update the one ' \
|
|
8
|
+
'matching the round brackets, upsert with <=, e.g. Posts(slug: \'hello\') <= { title: \'Hello\' }'
|
|
9
|
+
UPSERT_FILTER_MESSAGE = 'An upsert matches one row by plain values, e.g. Posts(slug: \'hello\') <= row, so it takes no ' \
|
|
10
|
+
'ranges, lists, blocks or tables in round brackets'
|
|
11
|
+
FOREIGN_KEY_MESSAGE = "A symbol in round brackets names a foreign key for a nested join, so it can't choose rows to %<action>s"
|
|
12
|
+
# `Posts[:id] <= Users[:id]` reads like a comparison or a join, so it says what `<=` does instead.
|
|
13
|
+
EXPRESSION_MESSAGES = {
|
|
14
|
+
'<<' => "<< inserts rows, e.g. Posts << { title: 'Hello' }, not a data expression",
|
|
15
|
+
'<=' => "<= upserts rows, e.g. Posts(slug: 'hello') <= { title: 'Hello' }, so it can't compare or join data expressions. " \
|
|
16
|
+
'Join them with &, <, > or <=>'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_rows!(rows:, operator:)
|
|
22
|
+
return if rows.is_a?(Hash) || (rows.is_a?(Array) && rows.all?(Hash))
|
|
23
|
+
raise ArgumentError, EXPRESSION_MESSAGES.fetch(operator) if rows.is_a?(DataExpression)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, format(ROWS_MESSAGE, operator:, rows: rows.inspect)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_insert_filter!(filter:)
|
|
29
|
+
raise ArgumentError, INSERT_FILTER_MESSAGE if chooses_rows?(filter:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether the round brackets choose rows. Foreign keys only name how a nested join links.
|
|
33
|
+
def chooses_rows?(filter:)
|
|
34
|
+
filter.values.any? || filter.conditions.any? || filter.related.any?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_upsert_filter!(filter:)
|
|
38
|
+
plain = filter.values.values.none? { |value| value.is_a?(Range) || value.is_a?(Enumerable) || value.is_a?(Sequel::SQL::Expression) }
|
|
39
|
+
return if plain && filter.conditions.empty? && filter.related.empty? && filter.foreign_keys.empty?
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, UPSERT_FILTER_MESSAGE
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# An update or delete without round brackets would change every row, so it raises. `dataset` can still do that.
|
|
45
|
+
def check_chosen!(source:, action:, example:)
|
|
46
|
+
filter = source.filter
|
|
47
|
+
raise ArgumentError, format(FOREIGN_KEY_MESSAGE, action:) if filter.foreign_keys.any?
|
|
48
|
+
return if chooses_rows?(filter:)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "Choose the #{source.table} to #{action} with round brackets, e.g. #{example}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Checked before the write, so a typo doesn't write a row and then fail to return it.
|
|
54
|
+
def check_returned!(source:, columns:)
|
|
55
|
+
missing = (source.columns + source.renames.values) - columns
|
|
56
|
+
raise ArgumentError, "#{source.table} has no #{missing.join(', ')} column to return" if missing.any?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def assignment(values:)
|
|
60
|
+
return "[:#{values.keys.first}] = #{values.values.first.inspect}" if values.one?
|
|
61
|
+
|
|
62
|
+
"[] = #{values.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/writer.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'write_checks'
|
|
4
|
+
|
|
5
|
+
class LowData
|
|
6
|
+
# Runs writes as soon as they're written, and returns what they wrote. See ADR 0007:
|
|
7
|
+
# Posts << { title: 'Hello' } # INSERT, returning the primary key
|
|
8
|
+
# Posts[:id, :title] << { title: 'Hello' } # INSERT, returning the columns in square brackets
|
|
9
|
+
# Posts(slug: 'hello') <= { title: 'Hello' } # INSERT ... ON CONFLICT (slug) DO UPDATE, returning the primary key
|
|
10
|
+
# Posts(id: 1)[:title] = 'New' # UPDATE ... WHERE (posts.id = 1)
|
|
11
|
+
# Posts(id: 1).delete # DELETE ... WHERE (posts.id = 1), returning the number of rows deleted
|
|
12
|
+
class Writer
|
|
13
|
+
include WriteChecks
|
|
14
|
+
|
|
15
|
+
def initialize(database:)
|
|
16
|
+
raise ConfigError, ConfigError::MISSING_DATABASE if database.nil?
|
|
17
|
+
|
|
18
|
+
@database = database
|
|
19
|
+
@schema = Schema.new(database:)
|
|
20
|
+
@filters = FilterCompiler.new(database:, schema: @schema)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def insert(source:, rows:)
|
|
24
|
+
check_rows!(rows:, operator: '<<')
|
|
25
|
+
check_insert_filter!(filter: source.filter)
|
|
26
|
+
|
|
27
|
+
each_row(rows:) { |row| insert_row(source:, row:) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The round brackets name the columns to match on, and their values are written too.
|
|
31
|
+
def upsert(source:, rows:)
|
|
32
|
+
check_rows!(rows:, operator: '<=')
|
|
33
|
+
check_upsert_filter!(filter: source.filter)
|
|
34
|
+
|
|
35
|
+
each_row(rows:) { |row| upsert_row(source:, row: source.filter.values.merge(row)) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update(source:, values:)
|
|
39
|
+
check_chosen!(source:, action: 'update', example: "#{constant(source:)}(id: 1)#{assignment(values:)}")
|
|
40
|
+
|
|
41
|
+
chosen(source:).update(values)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(source:)
|
|
45
|
+
check_chosen!(source:, action: 'delete', example: "#{constant(source:)}(id: 1).delete")
|
|
46
|
+
|
|
47
|
+
chosen(source:).delete
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A hash writes one row and returns a hash, and an array writes each row in a transaction and returns an array.
|
|
53
|
+
def each_row(rows:, &)
|
|
54
|
+
return yield(rows) if rows.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
@database.transaction { rows.map(&) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def insert_row(source:, row:)
|
|
60
|
+
dataset = @database[source.table]
|
|
61
|
+
return dataset.returning(*returned(source:)).insert(row).first if dataset.supports_returning?(:insert)
|
|
62
|
+
|
|
63
|
+
id = dataset.insert(row)
|
|
64
|
+
read_back(source:, keys: inserted_keys(table: source.table, row:, id:))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def upsert_row(source:, row:)
|
|
68
|
+
target = conflict_target(source:, row:)
|
|
69
|
+
return mysql_upsert(source:, row:, target:) if @database.database_type == :mysql
|
|
70
|
+
|
|
71
|
+
dataset = @database[source.table]
|
|
72
|
+
raise Error, "LowData can't upsert on #{@database.database_type}" unless dataset.respond_to?(:insert_conflict)
|
|
73
|
+
|
|
74
|
+
dataset = dataset.insert_conflict(target:, update: updates(row:, target:))
|
|
75
|
+
return dataset.returning(*returned(source:)).insert(row).first if dataset.supports_returning?(:insert)
|
|
76
|
+
|
|
77
|
+
dataset.insert(row)
|
|
78
|
+
read_back(source:, keys: row.slice(*target))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# MySQL's ON DUPLICATE KEY UPDATE matches any unique key, so the named columns must be the table's only one.
|
|
82
|
+
def mysql_upsert(source:, row:, target:)
|
|
83
|
+
unless unique_keys(table: source.table).map(&:sort) == [target.sort]
|
|
84
|
+
raise Error, "MySQL matches an upsert on any unique key, so #{target.join(', ')} must be #{source.table}'s only unique key"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@database[source.table].on_duplicate_key_update(*updates(row:, target:).keys).insert(row)
|
|
88
|
+
read_back(source:, keys: row.slice(*target))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unique_keys(table:)
|
|
92
|
+
indexes = @database.indexes(table).values.select { |index| index[:unique] }.map { |index| index[:columns] }
|
|
93
|
+
([@schema.primary_key(table:)] + indexes).reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Without round brackets, an upsert matches on the primary key.
|
|
97
|
+
def conflict_target(source:, row:)
|
|
98
|
+
return source.filter.values.keys if source.filter.values.any?
|
|
99
|
+
|
|
100
|
+
primary_key = @schema.primary_key(table: source.table)
|
|
101
|
+
missing = primary_key - row.keys
|
|
102
|
+
return primary_key if primary_key.any? && missing.empty?
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "#{constant(source:)} <= row matches on the primary key, so the row needs #{missing.join(', ')}. " \
|
|
105
|
+
"Or name the columns to match in round brackets, e.g. #{constant(source:)}(slug: 'hello') <= row"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A row of only the matched columns still updates one of them, so the database returns the row rather than nothing.
|
|
109
|
+
def updates(row:, target:)
|
|
110
|
+
columns = row.keys - target
|
|
111
|
+
(columns.empty? ? [target.first] : columns).to_h { |column| [column, Sequel[:excluded][column]] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Square brackets choose the columns returned, and a bare table returns its primary key, or every column without one.
|
|
115
|
+
def returned(source:)
|
|
116
|
+
return @schema.primary_key(table: source.table) if source.every_column?
|
|
117
|
+
|
|
118
|
+
check_returned!(source:, columns: @schema.columns(table: source.table))
|
|
119
|
+
source.columns + source.renames.map { |name, column| Sequel.as(column, name) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Databases without RETURNING read the row back by its keys.
|
|
123
|
+
def read_back(source:, keys:)
|
|
124
|
+
@database[source.table].where(keys).select(*returned(source:)).first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inserted_keys(table:, row:, id:)
|
|
128
|
+
primary_key = @schema.primary_key(table:)
|
|
129
|
+
return row if primary_key.empty?
|
|
130
|
+
return { primary_key.first => id } if primary_key.one? && !row.key?(primary_key.first)
|
|
131
|
+
|
|
132
|
+
row.slice(*primary_key)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def chosen(source:)
|
|
136
|
+
@filters.apply(dataset: @database[source.table], source:)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def constant(source:)
|
|
140
|
+
LowData.constant_name(source.table)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|