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.
@@ -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 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
+ # 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 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)
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(operands: operands(node:).map { |operand| build(node: operand) })
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`. FilterCompiler links related tables the same way.
25
- def link_to(parent:, child:, key:)
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
- named = named_link(links: to_one, key:)
28
- return one(parent:, child:, link: named, key:) if named
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.any?
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 + 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]
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
- # 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:)
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 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
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
- def with_children(node:, children:)
85
- children = node.children + children
86
- check_keys!(table: node.source.table, children:)
87
- node.with(children:)
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
- 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
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 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
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
- def check_keys!(table:, children:)
104
- keys = children.map(&:key)
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 #{table} as #{duplicate}. Name them, e.g. author: Users[:username]" if duplicate
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
- def linked?(table, other)
110
- @schema.links(from: table, to: other).any? || @schema.links(from: other, to: table).any?
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:)
@@ -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
 
@@ -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 &, <, > or <=>'
8
- ORDERED_JOIN_MESSAGE = 'Sort and limit after joining, e.g. (Users[] < Posts[]) ^ Col[:username]. Ruby binds ^ tighter ' \
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
- def compile(node:)
21
- return compile_ordered(node:) if node.is_a?(DataExpression::Ordered)
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
- dataset.select(*sources.flat_map { |source| selections(source:) })
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.type == :nest
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.columns.empty?
104
+ return [Sequel[source.table].*] if source.every_column?
87
105
 
88
- source.columns.map { |column| Sequel[source.table][column] }
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
- 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
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[] < Posts[] & Comments[]` runs `Posts[] & Comments[]` first ' \
104
- 'because & binds tighter than <. Add parentheses to join from the left: `(Users[] < Posts[]) & Comments[]`'
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[] * 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
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
- (outgoing + incoming).uniq - [table]
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
- def references(from:, to:)
36
- links(from:, to:).map do |link|
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