bespoke 0.1.2 → 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/bespoke.gemspec +3 -0
- data/lib/bespoke.rb +4 -37
- data/lib/bespoke/dsl.rb +4 -7
- data/lib/bespoke/join.rb +57 -0
- data/lib/bespoke/projection.rb +35 -0
- data/lib/bespoke/template.rb +45 -0
- data/lib/bespoke/version.rb +2 -2
- data/lib/bespoke/xsltproc.rb +83 -0
- data/spec/fixtures/books.xml +12 -0
- data/spec/fixtures/books_to_csv.xslt +17 -0
- data/spec/fixtures/one.csv +3 -0
- data/spec/fixtures/sif.student_personal.xslt +101 -0
- data/spec/fixtures/sif.xslt.mustache +85 -0
- data/spec/fixtures/two.csv +4 -0
- data/spec/join_spec.rb +44 -0
- data/spec/spec_helper.rb +6 -1
- data/spec/template_spec.rb +36 -0
- data/spec/xsltproc_spec.rb +24 -0
- metadata +72 -10
- data/lib/bespoke/exportable.rb +0 -82
- data/lib/bespoke/indexed_collection.rb +0 -45
- data/spec/bespoke_spec.rb +0 -112
- data/spec/exportable_spec.rb +0 -74
- data/spec/indexed_collection_spec.rb +0 -7
data/bespoke.gemspec
CHANGED
@@ -19,8 +19,11 @@ Gem::Specification.new do |gem|
|
|
19
19
|
|
20
20
|
gem.add_development_dependency "bundler", ">= 1.0.0"
|
21
21
|
gem.add_development_dependency "rspec", "~> 2.6"
|
22
|
+
gem.add_development_dependency "debugger"
|
22
23
|
|
23
24
|
gem.add_dependency 'rake'
|
24
25
|
gem.add_dependency 'docile'
|
25
26
|
gem.add_dependency 'mustache'
|
27
|
+
gem.add_dependency 'sqlite3'
|
28
|
+
gem.add_dependency 'sequel'
|
26
29
|
end
|
data/lib/bespoke.rb
CHANGED
@@ -1,39 +1,6 @@
|
|
1
1
|
require "bespoke/version"
|
2
|
-
require "bespoke/indexed_collection"
|
3
|
-
require "bespoke/exportable"
|
4
2
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
@collection = IndexedCollection.new
|
10
|
-
hash["index"].each_pair do |name, column|
|
11
|
-
@collection.index name, column
|
12
|
-
end
|
13
|
-
@exports = {}
|
14
|
-
hash["export"].each_pair do |output_name, exportable_configs|
|
15
|
-
outputs = @exports[output_name] = []
|
16
|
-
exportable_configs.each do |config|
|
17
|
-
config.each_pair do |collection_name, attrs|
|
18
|
-
outputs << (export = Exportable.new(collection_name, logger))
|
19
|
-
(attrs["fields"] || {}).each_pair do |field, template|
|
20
|
-
export.field field, template
|
21
|
-
end
|
22
|
-
(attrs["joins"] || {}).each_pair do |join, template|
|
23
|
-
export.join join, template
|
24
|
-
end
|
25
|
-
end
|
26
|
-
end
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
|
-
def add(type, object)
|
31
|
-
@collection.add(type, object)
|
32
|
-
end
|
33
|
-
|
34
|
-
def export(name, &block)
|
35
|
-
@exports[name].each do |e|
|
36
|
-
e.export(@collection.collections, &block)
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|
3
|
+
require "bespoke/template"
|
4
|
+
require "bespoke/xsltproc"
|
5
|
+
require "bespoke/projection"
|
6
|
+
require "bespoke/join"
|
data/lib/bespoke/dsl.rb
CHANGED
@@ -1,9 +1,6 @@
|
|
1
1
|
require "bespoke"
|
2
|
+
require "docile"
|
2
3
|
|
3
|
-
def
|
4
|
-
Docile.dsl_eval(Bespoke::
|
5
|
-
end
|
6
|
-
|
7
|
-
def exportable(name, &block)
|
8
|
-
Docile.dsl_eval(Bespoke::Exportable.new(name), &block)
|
9
|
-
end
|
4
|
+
def projection(name, xpath_root=nil, &block)
|
5
|
+
Docile.dsl_eval(Bespoke::Projection.new(name, xpath_root), &block)
|
6
|
+
end
|
data/lib/bespoke/join.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'sequel'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Bespoke
|
6
|
+
class Join
|
7
|
+
def initialize(projections, log=nil)
|
8
|
+
@projections = projections
|
9
|
+
@log = log || Logger.new($stderr)
|
10
|
+
end
|
11
|
+
|
12
|
+
def database
|
13
|
+
return @database if @database
|
14
|
+
@database = Sequel.sqlite
|
15
|
+
create_tables
|
16
|
+
@database
|
17
|
+
end
|
18
|
+
|
19
|
+
def load_csv(tablename, filepath)
|
20
|
+
table = database[tablename.to_sym]
|
21
|
+
CSV.foreach(filepath, headers: true) do |row|
|
22
|
+
begin
|
23
|
+
table.insert(row.to_hash)
|
24
|
+
rescue Sequel::UniqueConstraintViolation => e
|
25
|
+
@log.warn "Unable to insert row #{row.inspect}: #{e}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def query(sql)
|
31
|
+
database[sql]
|
32
|
+
end
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
def create_tables
|
37
|
+
@projections.each { |prj| create_table(prj) }
|
38
|
+
end
|
39
|
+
|
40
|
+
def create_table(prj)
|
41
|
+
database.drop_table?(prj.name)
|
42
|
+
database.create_table(prj.name) do
|
43
|
+
prj.fields.each do |field|
|
44
|
+
if field.name.to_s == "ref_id"
|
45
|
+
column :ref_id, :string, :primary_key => true
|
46
|
+
else
|
47
|
+
column field.name, field.type
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Bespoke
|
2
|
+
class TypedField
|
3
|
+
attr_reader :name, :type, :xpath
|
4
|
+
|
5
|
+
# @name The name of the column or field
|
6
|
+
# @type The Sqlite3 type of the data
|
7
|
+
# @xpath The xpath query that retrieves the data from XML
|
8
|
+
def initialize(name, type, xpath)
|
9
|
+
@name = name
|
10
|
+
@type = type
|
11
|
+
@xpath = xpath
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class Projection
|
16
|
+
attr_reader :name, :fields, :xpath_root
|
17
|
+
|
18
|
+
def initialize(name, xpath_root=nil)
|
19
|
+
@name = name.to_s
|
20
|
+
@xpath_root = xpath_root
|
21
|
+
@fields = []
|
22
|
+
end
|
23
|
+
|
24
|
+
def field(name, type=:string, xpath=nil)
|
25
|
+
@fields << TypedField.new(name, type, xpath)
|
26
|
+
end
|
27
|
+
|
28
|
+
def xpath_hash
|
29
|
+
pairs = @fields.map do |field|
|
30
|
+
[field.name, field.xpath]
|
31
|
+
end.flatten(1)
|
32
|
+
Hash[*pairs]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'mustache'
|
2
|
+
require 'tempfile'
|
3
|
+
|
4
|
+
module Bespoke
|
5
|
+
class Template
|
6
|
+
attr_reader :template_file
|
7
|
+
|
8
|
+
def initialize(template_file)
|
9
|
+
@template_file = template_file
|
10
|
+
end
|
11
|
+
|
12
|
+
def render(object_type, projection)
|
13
|
+
fields = projection_fields(projection)
|
14
|
+
Mustache.render(template,
|
15
|
+
:object_type => object_type,
|
16
|
+
:projection => fields)
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
def template
|
21
|
+
@template ||= Mustache::Template.new(File.read(@template_file))
|
22
|
+
end
|
23
|
+
|
24
|
+
def default_function
|
25
|
+
'escapeQuote'
|
26
|
+
end
|
27
|
+
|
28
|
+
def projection_pair(value)
|
29
|
+
value = Array(value)
|
30
|
+
[value.first, value[1] || default_function]
|
31
|
+
end
|
32
|
+
|
33
|
+
def projection_fields(projection)
|
34
|
+
projection.each_with_index.map do |(field, value), i|
|
35
|
+
selector, function = projection_pair(value)
|
36
|
+
{
|
37
|
+
:field => field.to_s,
|
38
|
+
:function => function,
|
39
|
+
:selector => selector,
|
40
|
+
:last => i == (projection.size - 1)
|
41
|
+
}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/bespoke/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
|
2
|
-
VERSION = '0.
|
1
|
+
module Bespoke
|
2
|
+
VERSION = '0.2.0' unless defined?(Bespoke::VERSION)
|
3
3
|
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'open3'
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
module Bespoke
|
6
|
+
class XSLTProc
|
7
|
+
def initialize(xslt, logger=nil)
|
8
|
+
@xslt_file = write_tmp_file(xslt)
|
9
|
+
@logger = logger || Logger.new(STDERR)
|
10
|
+
end
|
11
|
+
|
12
|
+
def write_tmp_file(xslt)
|
13
|
+
file = Tempfile.new('bespoke')
|
14
|
+
file.write(xslt)
|
15
|
+
return file.path
|
16
|
+
ensure
|
17
|
+
file.close
|
18
|
+
end
|
19
|
+
|
20
|
+
def transform(text)
|
21
|
+
out_file = StringIO.new("", 'w')
|
22
|
+
in_file = StringIO.new(text, 'r')
|
23
|
+
transform_file(in_file, out_file)
|
24
|
+
out_file.string
|
25
|
+
end
|
26
|
+
|
27
|
+
def transform_file(in_file, out_file)
|
28
|
+
cmd = ["xsltproc"]
|
29
|
+
cmd << @xslt_file
|
30
|
+
|
31
|
+
if in_file.respond_to?(:read)
|
32
|
+
cmd << "-" # use stdin
|
33
|
+
else
|
34
|
+
cmd << in_file
|
35
|
+
end
|
36
|
+
|
37
|
+
need_to_close_out_file = false
|
38
|
+
if out_file.is_a?(String)
|
39
|
+
need_to_close_out_file = true
|
40
|
+
out_file_handle = File.open(out_file, "a+")
|
41
|
+
elsif out_file.respond_to?(:write)
|
42
|
+
out_file_handle = out_file
|
43
|
+
else
|
44
|
+
raise "Need a file handle or file name for data transform destination"
|
45
|
+
end
|
46
|
+
|
47
|
+
threads = []
|
48
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
49
|
+
if in_file.respond_to?(:read)
|
50
|
+
# Send data
|
51
|
+
threads << Thread.new do
|
52
|
+
stdin.write in_file.read(2**16) until in_file.eof?
|
53
|
+
stdin.close
|
54
|
+
end
|
55
|
+
else
|
56
|
+
# No need to send data, we're passing in the filename
|
57
|
+
stdin.close
|
58
|
+
end
|
59
|
+
|
60
|
+
# Capture data
|
61
|
+
threads << Thread.new do
|
62
|
+
until (line = stdout.gets).nil? do
|
63
|
+
out_file_handle.write line
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Capture error messages
|
68
|
+
threads << Thread.new do
|
69
|
+
until (line = stderr.gets).nil? do
|
70
|
+
@logger.warn line
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
threads.each{ |t| t.join }
|
75
|
+
wait_thr.join
|
76
|
+
end
|
77
|
+
rescue SystemCallError => e
|
78
|
+
@logger.error e
|
79
|
+
ensure
|
80
|
+
out_file_handle.close if need_to_close_out_file
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
2
|
+
|
3
|
+
<xsl:output method="text" standalone="no"/>
|
4
|
+
|
5
|
+
<xsl:param name="delim" select="','" />
|
6
|
+
<xsl:param name="quote" select="'"'" />
|
7
|
+
<xsl:param name="break" select="'
'" />
|
8
|
+
|
9
|
+
<xsl:template match="/books">
|
10
|
+
<xsl:for-each select="book">
|
11
|
+
<xsl:value-of select="$quote" /><xsl:value-of select="id"/><xsl:value-of select="$quote" /><xsl:value-of select="$delim" />
|
12
|
+
<xsl:value-of select="$quote" /><xsl:value-of select="title"/><xsl:value-of select="$quote" /><xsl:value-of select="$delim" />
|
13
|
+
<xsl:value-of select="$quote" /><xsl:value-of select="author"/><xsl:value-of select="$quote" /><xsl:value-of select="$break" />
|
14
|
+
</xsl:for-each>
|
15
|
+
</xsl:template>
|
16
|
+
|
17
|
+
</xsl:stylesheet>
|
@@ -0,0 +1,101 @@
|
|
1
|
+
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:sif="http://www.sifinfo.org/infrastructure/2.x" xmlns:csv="csv:csv">
|
2
|
+
<xsl:output method="text" encoding="utf-8"/>
|
3
|
+
|
4
|
+
<xsl:param name="delim" select="','"/>
|
5
|
+
<xsl:param name="quote" select="'"'"/>
|
6
|
+
<xsl:param name="break" select="'
'"/>
|
7
|
+
|
8
|
+
<xsl:template match="/">
|
9
|
+
<xsl:apply-templates select="//sif:StudentPersonal"/>
|
10
|
+
</xsl:template>
|
11
|
+
|
12
|
+
|
13
|
+
<xsl:template match="sif:StudentPersonal">
|
14
|
+
<xsl:value-of select="$quote"/>
|
15
|
+
<xsl:call-template name="escapeQuote">
|
16
|
+
<xsl:with-param name="text" select="@RefId"/>
|
17
|
+
</xsl:call-template>
|
18
|
+
<xsl:value-of select="$quote"/>
|
19
|
+
<xsl:value-of select="$delim"/>
|
20
|
+
<xsl:value-of select="$quote"/>
|
21
|
+
<xsl:call-template name="escapeQuote">
|
22
|
+
<xsl:with-param name="text" select="sif:LocalId"/>
|
23
|
+
</xsl:call-template>
|
24
|
+
<xsl:value-of select="$quote"/>
|
25
|
+
<xsl:value-of select="$delim"/>
|
26
|
+
<xsl:value-of select="$quote"/>
|
27
|
+
<xsl:call-template name="escapeQuote">
|
28
|
+
<xsl:with-param name="text" select="sif:Name/sif:FirstName"/>
|
29
|
+
</xsl:call-template>
|
30
|
+
<xsl:value-of select="$quote"/>
|
31
|
+
<xsl:value-of select="$delim"/>
|
32
|
+
<xsl:value-of select="$quote"/>
|
33
|
+
<xsl:call-template name="escapeQuote">
|
34
|
+
<xsl:with-param name="text" select="sif:Name[@Type='04']/sif:LastName"/>
|
35
|
+
</xsl:call-template>
|
36
|
+
<xsl:value-of select="$quote"/>
|
37
|
+
<xsl:value-of select="$delim"/>
|
38
|
+
<xsl:value-of select="$quote"/>
|
39
|
+
<xsl:call-template name="split-get-first">
|
40
|
+
<xsl:with-param name="text" select="sif:EmailList/sif:Email[@Type='Primary']"/>
|
41
|
+
</xsl:call-template>
|
42
|
+
<xsl:value-of select="$quote"/>
|
43
|
+
<xsl:value-of select="$break"/>
|
44
|
+
</xsl:template>
|
45
|
+
|
46
|
+
<!-- Function that prefixes any quotes (") in a string with a backslash -->
|
47
|
+
<xsl:template match="text/text()" name="escapeQuote">
|
48
|
+
<xsl:param name="text" select="."/>
|
49
|
+
|
50
|
+
<xsl:if test="string-length($text) > 0">
|
51
|
+
<xsl:value-of select="substring-before(concat($text, $quote), $quote)"/>
|
52
|
+
|
53
|
+
<xsl:if test="contains($text, $quote)">
|
54
|
+
<xsl:text>\</xsl:text><xsl:value-of select="$quote"/>
|
55
|
+
<xsl:call-template name="escapeQuote">
|
56
|
+
<xsl:with-param name="text" select="substring-after($text, $quote)"/>
|
57
|
+
</xsl:call-template>
|
58
|
+
</xsl:if>
|
59
|
+
</xsl:if>
|
60
|
+
</xsl:template>
|
61
|
+
|
62
|
+
<!-- Function to split on commas (or any separator), returning a list of elements -->
|
63
|
+
<xsl:template match="text/text()" name="split">
|
64
|
+
<xsl:param name="text" select="."/>
|
65
|
+
<xsl:param name="separator" select="','"/>
|
66
|
+
<xsl:choose>
|
67
|
+
<xsl:when test="not(contains($text, $separator))">
|
68
|
+
<xsl:call-template name="escapeQuote">
|
69
|
+
<xsl:with-param name="text" select="normalize-space($text)"/>
|
70
|
+
</xsl:call-template>
|
71
|
+
</xsl:when>
|
72
|
+
<xsl:otherwise>
|
73
|
+
<item>
|
74
|
+
<xsl:value-of select="normalize-space(substring-before($text, $separator))"/>
|
75
|
+
</item>
|
76
|
+
<xsl:call-template name="split">
|
77
|
+
<xsl:with-param name="text" select="substring-after($text, $separator)"/>
|
78
|
+
</xsl:call-template>
|
79
|
+
</xsl:otherwise>
|
80
|
+
</xsl:choose>
|
81
|
+
</xsl:template>
|
82
|
+
|
83
|
+
<!-- Function like "split", but returns just the first split element -->
|
84
|
+
<xsl:template match="text/text()" name="split-get-first">
|
85
|
+
<xsl:param name="text" select="."/>
|
86
|
+
<xsl:param name="separator" select="','"/>
|
87
|
+
<xsl:choose>
|
88
|
+
<xsl:when test="not(contains($text, $separator))">
|
89
|
+
<item>
|
90
|
+
<xsl:value-of select="normalize-space($text)"/>
|
91
|
+
</item>
|
92
|
+
</xsl:when>
|
93
|
+
<xsl:otherwise>
|
94
|
+
<xsl:call-template name="escapeQuote">
|
95
|
+
<xsl:with-param name="text" select="normalize-space(substring-before($text, $separator))"/>
|
96
|
+
</xsl:call-template>
|
97
|
+
</xsl:otherwise>
|
98
|
+
</xsl:choose>
|
99
|
+
</xsl:template>
|
100
|
+
|
101
|
+
</xsl:stylesheet>
|
@@ -0,0 +1,85 @@
|
|
1
|
+
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:sif="http://www.sifinfo.org/infrastructure/2.x" xmlns:csv="csv:csv">
|
2
|
+
<xsl:output method="text" encoding="utf-8"/>
|
3
|
+
|
4
|
+
<xsl:param name="delim" select="','"/>
|
5
|
+
<xsl:param name="quote" select="'"'"/>
|
6
|
+
<xsl:param name="break" select="'
'"/>
|
7
|
+
|
8
|
+
<xsl:template match="/">
|
9
|
+
<xsl:apply-templates select="//{{object_type}}"/>
|
10
|
+
</xsl:template>
|
11
|
+
|
12
|
+
|
13
|
+
<xsl:template match="{{object_type}}">
|
14
|
+
{{!-- Loop through each projection field to generate an xslt transform for the column --}}
|
15
|
+
{{#projection}}
|
16
|
+
<xsl:value-of select="$quote"/>
|
17
|
+
<xsl:call-template name="{{function}}">
|
18
|
+
<xsl:with-param name="text" select="{{selector}}"/>
|
19
|
+
</xsl:call-template>
|
20
|
+
<xsl:value-of select="$quote"/>
|
21
|
+
{{^last}}
|
22
|
+
<xsl:value-of select="$delim"/>
|
23
|
+
{{/last}}
|
24
|
+
{{#last}}
|
25
|
+
<xsl:value-of select="$break"/>
|
26
|
+
{{/last}}
|
27
|
+
{{/projection}}
|
28
|
+
</xsl:template>
|
29
|
+
|
30
|
+
<!-- Function that prefixes any quotes (") in a string with a backslash -->
|
31
|
+
<xsl:template match="text/text()" name="escapeQuote">
|
32
|
+
<xsl:param name="text" select="."/>
|
33
|
+
|
34
|
+
<xsl:if test="string-length($text) > 0">
|
35
|
+
<xsl:value-of select="substring-before(concat($text, $quote), $quote)"/>
|
36
|
+
|
37
|
+
<xsl:if test="contains($text, $quote)">
|
38
|
+
<xsl:text>\</xsl:text><xsl:value-of select="$quote"/>
|
39
|
+
<xsl:call-template name="escapeQuote">
|
40
|
+
<xsl:with-param name="text" select="substring-after($text, $quote)"/>
|
41
|
+
</xsl:call-template>
|
42
|
+
</xsl:if>
|
43
|
+
</xsl:if>
|
44
|
+
</xsl:template>
|
45
|
+
|
46
|
+
<!-- Function to split on commas (or any separator), returning a list of elements -->
|
47
|
+
<xsl:template match="text/text()" name="split">
|
48
|
+
<xsl:param name="text" select="."/>
|
49
|
+
<xsl:param name="separator" select="','"/>
|
50
|
+
<xsl:choose>
|
51
|
+
<xsl:when test="not(contains($text, $separator))">
|
52
|
+
<xsl:call-template name="escapeQuote">
|
53
|
+
<xsl:with-param name="text" select="normalize-space($text)"/>
|
54
|
+
</xsl:call-template>
|
55
|
+
</xsl:when>
|
56
|
+
<xsl:otherwise>
|
57
|
+
<item>
|
58
|
+
<xsl:value-of select="normalize-space(substring-before($text, $separator))"/>
|
59
|
+
</item>
|
60
|
+
<xsl:call-template name="split">
|
61
|
+
<xsl:with-param name="text" select="substring-after($text, $separator)"/>
|
62
|
+
</xsl:call-template>
|
63
|
+
</xsl:otherwise>
|
64
|
+
</xsl:choose>
|
65
|
+
</xsl:template>
|
66
|
+
|
67
|
+
<!-- Function like "split", but returns just the first split element -->
|
68
|
+
<xsl:template match="text/text()" name="split-get-first">
|
69
|
+
<xsl:param name="text" select="."/>
|
70
|
+
<xsl:param name="separator" select="','"/>
|
71
|
+
<xsl:choose>
|
72
|
+
<xsl:when test="not(contains($text, $separator))">
|
73
|
+
<item>
|
74
|
+
<xsl:value-of select="normalize-space($text)"/>
|
75
|
+
</item>
|
76
|
+
</xsl:when>
|
77
|
+
<xsl:otherwise>
|
78
|
+
<xsl:call-template name="escapeQuote">
|
79
|
+
<xsl:with-param name="text" select="normalize-space(substring-before($text, $separator))"/>
|
80
|
+
</xsl:call-template>
|
81
|
+
</xsl:otherwise>
|
82
|
+
</xsl:choose>
|
83
|
+
</xsl:template>
|
84
|
+
|
85
|
+
</xsl:stylesheet>
|
data/spec/join_spec.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe Bespoke::Join do
|
4
|
+
let(:projections) {
|
5
|
+
[
|
6
|
+
projection(:one) {
|
7
|
+
field :id, :integer
|
8
|
+
field :name, :string
|
9
|
+
},
|
10
|
+
projection(:two) {
|
11
|
+
field :id, :integer
|
12
|
+
field :one_id, :integer
|
13
|
+
field :description, :string
|
14
|
+
}
|
15
|
+
]
|
16
|
+
}
|
17
|
+
let(:join) { Bespoke::Join.new(projections) }
|
18
|
+
|
19
|
+
context "table 'one'" do
|
20
|
+
before { join.load_csv('one', fixture('one.csv')) }
|
21
|
+
|
22
|
+
it "loads csv" do
|
23
|
+
join.database[:one].count.should == 2
|
24
|
+
end
|
25
|
+
|
26
|
+
context "and table 'two'" do
|
27
|
+
before { join.load_csv('two', fixture('two.csv')) }
|
28
|
+
|
29
|
+
it "loads csv" do
|
30
|
+
join.database[:two].count.should == 3
|
31
|
+
end
|
32
|
+
|
33
|
+
it "joins" do
|
34
|
+
sql = "SELECT name, description FROM one LEFT JOIN two ON one.id = two.one_id"
|
35
|
+
join.query(sql).map { |row| row }.
|
36
|
+
should == [
|
37
|
+
{:name=>"bitcoin", :description=>"Cryptocurrency"},
|
38
|
+
{:name=>"bitcoin", :description=>"Investment"},
|
39
|
+
{:name=>"nxt", :description=>"Alternate Cryptocurrency"}
|
40
|
+
]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -0,0 +1,36 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe Bespoke::Template do
|
4
|
+
let(:projection) {
|
5
|
+
{
|
6
|
+
:ref_id => "@RefId",
|
7
|
+
:local_id => "sif:LocalId",
|
8
|
+
:first_name => "sif:Name/sif:FirstName",
|
9
|
+
:last_name => "sif:Name[@Type='04']/sif:LastName",
|
10
|
+
:email => ["sif:EmailList/sif:Email[@Type='Primary']", "split-get-first"]
|
11
|
+
}
|
12
|
+
}
|
13
|
+
let(:xslt_mustache) { fixture('sif.xslt.mustache') }
|
14
|
+
let(:xslt) { fixture('sif.student_personal.xslt') }
|
15
|
+
let(:tbuild) { Bespoke::Template.new(xslt_mustache) }
|
16
|
+
let(:fields) { tbuild.send(:projection_fields, projection) }
|
17
|
+
|
18
|
+
it "transforms" do
|
19
|
+
tbuild.render("sif:StudentPersonal", projection).should == File.read(xslt)
|
20
|
+
end
|
21
|
+
|
22
|
+
it "last element is the only element where 'last' is true" do
|
23
|
+
first = fields[0..-2].map{ |f| f[:last] }
|
24
|
+
first.all?{ |x| x == false }.should be_true
|
25
|
+
fields[-1][:last].should be_true
|
26
|
+
end
|
27
|
+
|
28
|
+
it "has optional function field" do
|
29
|
+
fields[-1].should == {
|
30
|
+
:field => "email",
|
31
|
+
:function => "split-get-first",
|
32
|
+
:selector => "sif:EmailList/sif:Email[@Type='Primary']",
|
33
|
+
:last => true
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe Bespoke::XSLTProc do
|
4
|
+
let(:xslt) { File.read(fixture('books_to_csv.xslt')) }
|
5
|
+
let(:xml_file) { fixture('books.xml') }
|
6
|
+
let(:trans) { Bespoke::XSLTProc.new(xslt) }
|
7
|
+
let(:data) { %("1","One","Deity"\n"2","Two","Lovers"\n) }
|
8
|
+
|
9
|
+
it "transforms" do
|
10
|
+
text = File.read(xml_file)
|
11
|
+
trans.transform(text).should == data
|
12
|
+
end
|
13
|
+
|
14
|
+
it "can append to files" do
|
15
|
+
tmp = Tempfile.new('test')
|
16
|
+
2.times do
|
17
|
+
File.open(tmp.path, "a+") do |out_file|
|
18
|
+
trans.transform_file(xml_file, out_file)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
output = File.read(tmp.path)
|
22
|
+
output.should == data * 2
|
23
|
+
end
|
24
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bespoke
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2014-
|
12
|
+
date: 2014-04-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|
@@ -43,6 +43,22 @@ dependencies:
|
|
43
43
|
- - ~>
|
44
44
|
- !ruby/object:Gem::Version
|
45
45
|
version: '2.6'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: debugger
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
46
62
|
- !ruby/object:Gem::Dependency
|
47
63
|
name: rake
|
48
64
|
requirement: !ruby/object:Gem::Requirement
|
@@ -91,6 +107,38 @@ dependencies:
|
|
91
107
|
- - ! '>='
|
92
108
|
- !ruby/object:Gem::Version
|
93
109
|
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: sqlite3
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: sequel
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ! '>='
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
type: :runtime
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
94
142
|
description: Bespoke does in-memory object joins using mustache templates
|
95
143
|
email:
|
96
144
|
- duane@instructure.com
|
@@ -101,14 +149,22 @@ files:
|
|
101
149
|
- bespoke.gemspec
|
102
150
|
- readme.md
|
103
151
|
- lib/bespoke/dsl.rb
|
104
|
-
- lib/bespoke/
|
105
|
-
- lib/bespoke/
|
152
|
+
- lib/bespoke/join.rb
|
153
|
+
- lib/bespoke/projection.rb
|
154
|
+
- lib/bespoke/template.rb
|
106
155
|
- lib/bespoke/version.rb
|
156
|
+
- lib/bespoke/xsltproc.rb
|
107
157
|
- lib/bespoke.rb
|
108
|
-
- spec/
|
109
|
-
- spec/
|
110
|
-
- spec/
|
158
|
+
- spec/fixtures/books.xml
|
159
|
+
- spec/fixtures/books_to_csv.xslt
|
160
|
+
- spec/fixtures/one.csv
|
161
|
+
- spec/fixtures/sif.student_personal.xslt
|
162
|
+
- spec/fixtures/sif.xslt.mustache
|
163
|
+
- spec/fixtures/two.csv
|
164
|
+
- spec/join_spec.rb
|
111
165
|
- spec/spec_helper.rb
|
166
|
+
- spec/template_spec.rb
|
167
|
+
- spec/xsltproc_spec.rb
|
112
168
|
homepage:
|
113
169
|
licenses: []
|
114
170
|
post_install_message:
|
@@ -134,8 +190,14 @@ signing_key:
|
|
134
190
|
specification_version: 3
|
135
191
|
summary: Bespoke does in-memory object joins using mustache templates
|
136
192
|
test_files:
|
137
|
-
- spec/
|
138
|
-
- spec/
|
139
|
-
- spec/
|
193
|
+
- spec/fixtures/books.xml
|
194
|
+
- spec/fixtures/books_to_csv.xslt
|
195
|
+
- spec/fixtures/one.csv
|
196
|
+
- spec/fixtures/sif.student_personal.xslt
|
197
|
+
- spec/fixtures/sif.xslt.mustache
|
198
|
+
- spec/fixtures/two.csv
|
199
|
+
- spec/join_spec.rb
|
140
200
|
- spec/spec_helper.rb
|
201
|
+
- spec/template_spec.rb
|
202
|
+
- spec/xsltproc_spec.rb
|
141
203
|
has_rdoc:
|
data/lib/bespoke/exportable.rb
DELETED
@@ -1,82 +0,0 @@
|
|
1
|
-
require 'csv'
|
2
|
-
require 'docile'
|
3
|
-
require 'mustache'
|
4
|
-
require 'logger'
|
5
|
-
|
6
|
-
class Bespoke
|
7
|
-
class Error < StandardError; end
|
8
|
-
MissingTable = Class.new(Error)
|
9
|
-
|
10
|
-
class Exportable
|
11
|
-
attr_accessor :name, :fields, :joins
|
12
|
-
|
13
|
-
def initialize(name, logger=nil)
|
14
|
-
@name = name.to_sym
|
15
|
-
@fields = {}
|
16
|
-
@joins = {}
|
17
|
-
@helpers = {}
|
18
|
-
@logger = logger || Logger.new(STDERR)
|
19
|
-
end
|
20
|
-
|
21
|
-
class HelperClass
|
22
|
-
def initialize(helpers, context)
|
23
|
-
@helpers, @context = helpers, context
|
24
|
-
end
|
25
|
-
|
26
|
-
def has_key?(key)
|
27
|
-
@helpers.has_key?(key) ||
|
28
|
-
@context.has_key?(key)
|
29
|
-
end
|
30
|
-
|
31
|
-
def [](key)
|
32
|
-
if @helpers.has_key?(key)
|
33
|
-
@helpers[key].call(@context)
|
34
|
-
elsif @context.has_key?(key)
|
35
|
-
@context[key]
|
36
|
-
else
|
37
|
-
super(key)
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def headers
|
43
|
-
@fields.keys
|
44
|
-
end
|
45
|
-
|
46
|
-
def helper(name, &block)
|
47
|
-
@helpers[name] = block
|
48
|
-
end
|
49
|
-
|
50
|
-
def field(name, template_string)
|
51
|
-
fields[name] = Mustache::Template.new(template_string)
|
52
|
-
end
|
53
|
-
|
54
|
-
def join(name, key)
|
55
|
-
joins[name] = key
|
56
|
-
end
|
57
|
-
|
58
|
-
def export(hashes={}, &block)
|
59
|
-
raise "hashes missing #{@name.inspect} (of: #{hashes.keys.inspect})" unless hashes.has_key?(@name)
|
60
|
-
hashes[@name].map do |main_key, row|
|
61
|
-
context = { @name => row }
|
62
|
-
context[:helper] = HelperClass.new(@helpers, context)
|
63
|
-
@joins.each_pair do |join_name, key|
|
64
|
-
if other_table = hashes[join_name.to_sym]
|
65
|
-
if other_table.has_key?(row[key])
|
66
|
-
context[join_name.to_sym] = other_table[row[key]]
|
67
|
-
else
|
68
|
-
@logger.warn "Expected foreign key #{key} with value #{row[key]} in table #{join_name} (#{row.inspect})"
|
69
|
-
end
|
70
|
-
else
|
71
|
-
raise MissingTable, "Expected #{join_name}"
|
72
|
-
end
|
73
|
-
end
|
74
|
-
fields.map do |name, template|
|
75
|
-
Mustache.render(template, context)
|
76
|
-
end.tap do |output_row|
|
77
|
-
yield output_row if block_given?
|
78
|
-
end
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
@@ -1,45 +0,0 @@
|
|
1
|
-
require 'docile'
|
2
|
-
|
3
|
-
class Bespoke
|
4
|
-
class IndexedCollection
|
5
|
-
attr_reader :collections
|
6
|
-
|
7
|
-
def initialize
|
8
|
-
@index_columns = {}
|
9
|
-
@collections = {}
|
10
|
-
end
|
11
|
-
|
12
|
-
def proc_for_key(key)
|
13
|
-
return Proc.new{ |x| nil } unless key
|
14
|
-
key = key.first if key.is_a?(Array) and key.size == 1
|
15
|
-
if key.is_a?(Array)
|
16
|
-
Proc.new{ |x| key.map{ |k| (x[k] rescue x.send(k)) } }
|
17
|
-
else
|
18
|
-
Proc.new{ |x| (x[key] rescue x.send(key)) }
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
def index(collection_name, index_key_method=nil, &block)
|
23
|
-
col_sym = collection_name.to_sym
|
24
|
-
@index_columns[col_sym] = block || proc_for_key(index_key_method)
|
25
|
-
@collections[col_sym] = {}
|
26
|
-
end
|
27
|
-
|
28
|
-
def add(collection_name, object)
|
29
|
-
col_sym = collection_name.to_sym
|
30
|
-
key_from_object = @index_columns[col_sym]
|
31
|
-
key = key_from_object.call(object)
|
32
|
-
begin
|
33
|
-
@collections[col_sym][key] = object
|
34
|
-
rescue NoMethodError
|
35
|
-
raise "Can't find collection #{col_sym} with key #{key}"
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
def find(collection_name, key)
|
40
|
-
if collection = @collections[collection_name.to_sym]
|
41
|
-
collection[key]
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
end
|
data/spec/bespoke_spec.rb
DELETED
@@ -1,112 +0,0 @@
|
|
1
|
-
require_relative 'spec_helper'
|
2
|
-
|
3
|
-
describe Bespoke do
|
4
|
-
let(:data) {
|
5
|
-
{
|
6
|
-
"student" => [
|
7
|
-
{ "id" => 1, "first_name" => "Eric", "last_name" => "Adams" },
|
8
|
-
{ "id" => 2, "first_name" => "Duane", "last_name" => "Johnson" },
|
9
|
-
{ "id" => 3, "first_name" => "Ken", "last_name" => "Romney" }
|
10
|
-
],
|
11
|
-
"staff" => [
|
12
|
-
{ "id" => 1, "last_name" => "Baxter", "school_id" => 1 },
|
13
|
-
{ "id" => 2, "last_name" => "Summer", "school_id" => 2 }
|
14
|
-
],
|
15
|
-
"school" => [
|
16
|
-
{ "id" => 1, "district" => "North" },
|
17
|
-
{ "id" => 2, "district" => "East" },
|
18
|
-
{ "id" => 3, "district" => "South" }
|
19
|
-
]
|
20
|
-
}
|
21
|
-
}
|
22
|
-
let(:config) {
|
23
|
-
{
|
24
|
-
"index" => {
|
25
|
-
"student" => ["id"],
|
26
|
-
"staff" => ["id"],
|
27
|
-
"school" => ["id"]
|
28
|
-
},
|
29
|
-
"export" => {
|
30
|
-
"users" => [
|
31
|
-
{
|
32
|
-
"student" => {
|
33
|
-
"fields" => {
|
34
|
-
"user_id" => "{{student.id}}",
|
35
|
-
"name" => "{{student.first_name}} {{student.last_name}}"
|
36
|
-
}
|
37
|
-
}
|
38
|
-
},
|
39
|
-
{
|
40
|
-
"staff" => {
|
41
|
-
"fields" => {
|
42
|
-
"user_id" => "{{staff.id}}",
|
43
|
-
"name" => "{{school.district}} Professor {{staff.last_name}}"
|
44
|
-
},
|
45
|
-
"joins" => {
|
46
|
-
"school" => "school_id"
|
47
|
-
}
|
48
|
-
}
|
49
|
-
}
|
50
|
-
],
|
51
|
-
"schools" => [
|
52
|
-
{
|
53
|
-
"school" => {
|
54
|
-
"fields" => {
|
55
|
-
"school_id" => "{{school.id}}",
|
56
|
-
"district" => "D:{{school.district}}"
|
57
|
-
}
|
58
|
-
}
|
59
|
-
}
|
60
|
-
]
|
61
|
-
}
|
62
|
-
}
|
63
|
-
}
|
64
|
-
let(:bespoke) { Bespoke.new(config) }
|
65
|
-
|
66
|
-
it "initializes" do
|
67
|
-
Bespoke.new(config)
|
68
|
-
end
|
69
|
-
|
70
|
-
context "with loaded data" do
|
71
|
-
before do
|
72
|
-
data.each_pair do |type, rows|
|
73
|
-
rows.each do |row|
|
74
|
-
bespoke.add type, row
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
78
|
-
|
79
|
-
it "creates a collection in memory" do
|
80
|
-
bespoke.collection.collections.should == {
|
81
|
-
:student => {
|
82
|
-
1 => {"id"=>1, "first_name"=>"Eric", "last_name"=>"Adams"},
|
83
|
-
2 => {"id"=>2, "first_name"=>"Duane", "last_name"=>"Johnson"},
|
84
|
-
3 => {"id"=>3, "first_name"=>"Ken", "last_name"=>"Romney"}
|
85
|
-
},
|
86
|
-
:staff => {
|
87
|
-
1 => {"id"=>1, "last_name"=>"Baxter", "school_id"=>1},
|
88
|
-
2 => {"id"=>2, "last_name"=>"Summer", "school_id"=>2}
|
89
|
-
},
|
90
|
-
:school => {
|
91
|
-
1 => {"id"=>1, "district"=>"North"},
|
92
|
-
2 => {"id"=>2, "district"=>"East"},
|
93
|
-
3 => {"id"=>3, "district"=>"South"}
|
94
|
-
}
|
95
|
-
}
|
96
|
-
end
|
97
|
-
|
98
|
-
it "exports" do
|
99
|
-
rows = []
|
100
|
-
bespoke.export("users") do |row|
|
101
|
-
rows << row
|
102
|
-
end
|
103
|
-
rows.should == [
|
104
|
-
["1", "Eric Adams"],
|
105
|
-
["2", "Duane Johnson"],
|
106
|
-
["3", "Ken Romney"],
|
107
|
-
["1", "North Professor Baxter"],
|
108
|
-
["2", "East Professor Summer"]
|
109
|
-
]
|
110
|
-
end
|
111
|
-
end
|
112
|
-
end
|
data/spec/exportable_spec.rb
DELETED
@@ -1,74 +0,0 @@
|
|
1
|
-
require_relative 'spec_helper'
|
2
|
-
require 'logger'
|
3
|
-
require 'stringio'
|
4
|
-
|
5
|
-
describe Bespoke::Exportable do
|
6
|
-
let(:logger) { nil }
|
7
|
-
let(:export) { Bespoke::Exportable.new(:test, logger) }
|
8
|
-
let(:test_data) {
|
9
|
-
{
|
10
|
-
1 => {:one => 1, :two => 2},
|
11
|
-
2 => {:one => 5, :two => 10}
|
12
|
-
}
|
13
|
-
}
|
14
|
-
|
15
|
-
before do
|
16
|
-
export.field(:column, "{{test.one}}-{{test.two}}")
|
17
|
-
end
|
18
|
-
|
19
|
-
it "initializes" do
|
20
|
-
export
|
21
|
-
end
|
22
|
-
|
23
|
-
it "exports templated rows" do
|
24
|
-
data = export.export(:test => test_data)
|
25
|
-
data.should == [["1-2"], ["5-10"]]
|
26
|
-
end
|
27
|
-
|
28
|
-
context "helper methods" do
|
29
|
-
it "can be declared" do
|
30
|
-
export.field(:helped, "{{helper.helper_method}}")
|
31
|
-
export.helper(:helper_method) { |row| "help!" }
|
32
|
-
|
33
|
-
data = export.export(:test => test_data)
|
34
|
-
data.should == [["1-2", "help!"], ["5-10", "help!"]]
|
35
|
-
end
|
36
|
-
|
37
|
-
it "has access to row data" do
|
38
|
-
export.field(:helped, "{{helper.fallback}}")
|
39
|
-
export.helper(:fallback) do |row|
|
40
|
-
row[:test][:one] || row[:test][:two]
|
41
|
-
end
|
42
|
-
|
43
|
-
data = export.export(:test =>
|
44
|
-
{
|
45
|
-
1 => {:two => 2},
|
46
|
-
2 => {:one => 5, :two => 10}
|
47
|
-
}
|
48
|
-
)
|
49
|
-
data.should == [["-2", "2"], ["5-10", "5"]]
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
context "logging" do
|
54
|
-
let(:output) { StringIO.new }
|
55
|
-
let(:logger) { Logger.new(output) }
|
56
|
-
|
57
|
-
it "logs missing join data" do
|
58
|
-
export.field(:column, "{{test.id}}-{{test.value}}")
|
59
|
-
export.join(:user, :id)
|
60
|
-
data = export.export({
|
61
|
-
:user => {
|
62
|
-
1 => {:test_id => 1, :name => "Duane"},
|
63
|
-
2 => {:test_id => 2, :name => "Ken"},
|
64
|
-
},
|
65
|
-
:test => {
|
66
|
-
1 => {:id => 1, :value => 2},
|
67
|
-
2 => {:id => 5, :value => 10}
|
68
|
-
}
|
69
|
-
})
|
70
|
-
log_msg = output.string.split(' : ')[1]
|
71
|
-
log_msg.should == "Expected foreign key id with value 5 in table user ({:id=>5, :value=>10})\n"
|
72
|
-
end
|
73
|
-
end
|
74
|
-
end
|