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.
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 the
5
- # operands of a + chain nest by following foreign keys from the first operand.
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 build(node: node.node).with(keys: node.keys, rows: node.rows) if node.is_a?(DataExpression::Ordered)
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(operands: operands(node:).map { |operand| build(node: operand) })
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`. FilterCompiler links related tables the same way.
25
- def link_to(parent:, child:, key:)
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
- named = named_link(links: to_one, key:)
28
- return one(parent:, child:, link: named, key:) if named
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.any?
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 + chain is left deep, and each operand is a table, a bracketed table or a parenthesised + chain.
41
- def operands(node:)
42
- return [node] unless node.is_a?(DataExpression::Join)
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
- # Links the operands into a tree from the first. Every operand must be linked, and only by one path.
60
- def chain(operands:)
61
- check_duplicates!(operands:)
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
- # 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:)
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
- def with_children(node:, children:)
85
- children = node.children + children
86
- check_keys!(table: node.source.table, children:)
87
- node.with(children:)
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
- def check_duplicates!(operands:)
91
- tables = operands.map { |operand| operand.source.table }
92
- duplicate = tables.find { |table| tables.count(table) > 1 }
93
- raise JoinError, "Can't join #{duplicate} to itself. Name each in brackets, e.g. author: Users[:username]" if duplicate
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 check_chain!(operands:, visited:, edges:)
97
- tables = operands.map { |operand| operand.source.table }
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
- def check_keys!(table:, children:)
104
- keys = children.map(&:key)
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 #{table} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
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
- def linked?(table, other)
110
- @schema.links(from: table, to: other).any? || @schema.links(from: other, to: table).any?
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:)
@@ -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 &, <, > or <=>'
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(left_tables: sources.map(&:table), right_table: right.table)
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.type == :nest
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.columns.empty?
87
+ return [Sequel[source.table].*] if source.every_column?
87
88
 
88
- source.columns.map { |column| Sequel[source.table][column] }
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
- def join_conditions(left_tables:, right_table:)
92
- candidates = left_tables.flat_map do |left_table|
93
- @schema.references(from: right_table, to: left_table) + @schema.references(from: left_table, to: right_table)
94
- end
95
-
96
- raise JoinError, "No foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.empty?
97
- raise JoinError, "More than one foreign key links #{right_table} to #{left_tables.join(', ')}" if candidates.length > 1
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
- def references(from:, to:)
36
- links(from:, to:).map do |link|
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
@@ -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
- nested = nested(pairs: expressions.map { |expression| [nil, expression] } + named(keywords:))
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
- def named(keywords:)
80
- named, filters = keywords.partition { |_key, value| value.is_a?(DataExpression) }
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' if keywords.any?
154
+ raise ArgumentError, 'Table[] takes column names only, and renames such as headline: :title' unless keywords.values.all?(Symbol)
93
155
 
94
- DataExpression.new(node: DataExpression::Inversion.new(table:, columns: arguments, filter:))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class LowData
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -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