Home > Miscellaneous > XML Flattening revisited : Java-based pipelined function

XML Flattening revisited : Java-based pipelined function

November 18, 2012 Leave a comment Go to comments

As a follow-up to How To : Flatten out an XML Hierarchical Structure, here’s a fourth approach using a pipelined function built over the Java InfosetReader interface.
The function only works on an XMLType column/table stored as binary XML. Since we directly decode the binary stream and pipe the rows to the SQL engine, this method is faster, much more scalable and less memory-intensive.

 

1. Set up

The ODCI set up is based on : Pipelined Table Functions Example: Java Implementation from the Data Cartridge Developer’s Guide.

We start by creating an object type and its collection. These are the structures that will be filled by the Java program at runtime and returned to the SQL engine via the pipelined function :

CREATE TYPE XMLEdgeTableRow AS OBJECT (
  node_id        integer
, node_name      varchar2(2000)
, node_type      varchar2(30)
, parent_node_id integer
, node_value     varchar2(4000)
, namespace_uri  varchar2(2000)
);

CREATE TYPE XMLEdgeTable AS TABLE OF XMLEdgeTableRow;

 

then the implementation type :

CREATE OR REPLACE TYPE XMLEdgeTableImpl AS OBJECT (

  key integer

, STATIC FUNCTION ODCITableStart(sctx OUT XMLEdgeTableImpl, doc XMLType, textAsValue binary_integer) RETURN NUMBER
    AS LANGUAGE JAVA
    NAME 'XMLEdgeTableImpl.ODCITableStart(oracle.sql.STRUCT[], oracle.xdb.XMLType, boolean) return java.math.BigDecimal'

, MEMBER FUNCTION ODCITableFetch(self IN OUT XMLEdgeTableImpl, nrows IN NUMBER, outSet OUT XMLEdgeTable) RETURN NUMBER
    AS LANGUAGE JAVA
    NAME 'XMLEdgeTableImpl.ODCITableFetch(java.math.BigDecimal, oracle.sql.ARRAY[]) return java.math.BigDecimal'

, MEMBER FUNCTION ODCITableClose(self IN XMLEdgeTableImpl) RETURN NUMBER
    AS LANGUAGE JAVA
    NAME 'XMLEdgeTableImpl.ODCITableClose() return java.math.BigDecimal'

);

 

and the PL/SQL pipelined function based on that implementation :

CREATE OR REPLACE FUNCTION XMLFlatten(
  doc         in XMLType
, textAsValue in binary_integer
)
RETURN XMLEdgeTable PIPELINED 
USING XMLEdgeTableImpl;

The function possesses two parameters that will be passed to the ODCITableStart method to initialize the context :

  • doc : the binary XMLType instance
  • textAsValue : controls if elements having a single text() child node must use it for NODE_VALUE, instead of putting the text() node in a separate row (1 = yes, 0 = no)

 

2. The Java implementation

Last but not least, here’s the Java source. Among a few other things we’ll see shortly, it implements our three ODCI methods ODCITableStart, ODCITableFetch and ODCITableClose declared in the implementation type :

create or replace and compile java source named xmledgetablesrc as
import java.sql.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Stack;

import oracle.sql.*;
import oracle.CartridgeServices.*;
import oracle.xdb.XMLType;
import oracle.xml.binxml.*;
import oracle.xml.scalable.InfosetReader;
import oracle.xml.parser.v2.TypedAttributes;
import oracle.xml.parser.v2.XMLConstants;

// implementation type
public class XMLEdgeTableImpl implements SQLData {

    // local structure to handle a row
    private static class XMLEdgeTableRow {
        private Object[] out_attr;

        private XMLEdgeTableRow() {
            this.out_attr = new Object[6];
        }

        private void setNodeValue(String nodeValue) {
            this.out_attr[4] = (Object) nodeValue;
        }

        private void setRow(Integer nodeId, String qName, String nodeKind, Integer parentNodeId, 
                String nodeValue, String namespaceUri) {
            this.out_attr[0] = (Object) nodeId;
            this.out_attr[1] = (Object) qName;
            this.out_attr[2] = (Object) nodeKind;
            this.out_attr[3] = (Object) parentNodeId;
            this.out_attr[4] = (Object) nodeValue;
            this.out_attr[5] = (Object) namespaceUri;
        }

        private STRUCT getRow() throws SQLException {
            return new STRUCT(outDesc, conn, out_attr);
        }

        private STRUCT getRow(Integer nodeId, String qName, String nodeKind, Integer parentNodeId, 
                String nodeValue, String namespaceUri) throws SQLException {
            this.setRow(nodeId, qName, nodeKind, parentNodeId, nodeValue, namespaceUri);
            return this.getRow();
        }
    }

    // stored context type
    private static class StoredCtx {
        InfosetReader reader;
        Stack<Integer> st;    // Stack instance storing open element(s)
        int nodeCount;
        short flag;           // for mixed content detection
        String tmp_text;
        XMLEdgeTableRow r2;   // temp row instance for an element
        ArrayList<STRUCT> v2; // temp storage for attribute list
        int ev;

        public StoredCtx(InfosetReader rd) {
            this.reader = rd;
            this.nodeCount = 0;
            this.st = new Stack<Integer>();
            this.flag = 0;
            this.tmp_text = null;
            this.r2 = new XMLEdgeTableRow();
            this.v2 = new ArrayList<STRUCT>(10);
            this.ev = 0;
        }
    }

    private BigDecimal key;
    final static BigDecimal SUCCESS = new BigDecimal(0);
    final static BigDecimal ERROR = new BigDecimal(1);

    // Implement SQLData interface.
    String sql_type;
    public String getSQLTypeName() throws SQLException {
        return sql_type;
    }

    public void readSQL(SQLInput stream, String typeName) throws SQLException {
        sql_type = typeName;
        key = stream.readBigDecimal();
    }

    public void writeSQL(SQLOutput stream) throws SQLException {
        stream.writeBigDecimal(key);
    }

    private static StructDescriptor outDesc;
    private static Connection conn;
    private static boolean singleTextAsValue;

    // type methods implementing ODCITable interface
    // ===============================================================
    //    ODCITableStart
    // ===============================================================
    static public BigDecimal ODCITableStart(STRUCT[] sctx, XMLType doc, boolean textAsValue)
            throws SQLException, BinXMLException {

        conn = DriverManager.getConnection("jdbc:default:connection:");
        singleTextAsValue = textAsValue;

        // create a stored context and store the InfosetReader in it
        BinXMLStream bstr = doc.getBinXMLStream();
        BinXMLDecoder dec = bstr.getDecoder();
        InfosetReader rd = dec.getReader();  
        StoredCtx ctx = new StoredCtx(rd);

        outDesc = StructDescriptor.createDescriptor("XMLEDGETABLEROW",conn);

        // register stored context with cartridge services
        int key;
        try {
            key = ContextManager.setContext(ctx);
        } catch (CountException ce) {
            return ERROR;
        }

        // create an XMLEdgeTableImpl instance and store the key in it
        Object[] impAttr = new Object[1];
        impAttr[0] = new BigDecimal(key); 
        StructDescriptor sd = new StructDescriptor("XMLEDGETABLEIMPL",conn);
        sctx[0] = new STRUCT(sd,conn,impAttr);

        return SUCCESS;
    }

    // ===============================================================
    //    ODCITableFetch
    // ===============================================================
    public BigDecimal ODCITableFetch(BigDecimal nrows, ARRAY[] outSet)
            throws SQLException {
        // retrieve stored context using the key
        StoredCtx ctx;
        try {
            ctx = (StoredCtx)ContextManager.getContext(key.intValue());
        } catch (InvalidKeyException ik) {
            return ERROR;
        }

        // get the nrows parameter, but return up to 8k rows
        int nrowsval = nrows.intValue();
        if (nrowsval > 8192) {
            nrowsval = 8192;
        }
        // create a collection for the fetched rows
        ArrayList<STRUCT> v = new ArrayList<STRUCT>(1024);
        XMLEdgeTableRow r = new XMLEdgeTableRow();
        TypedAttributes attrList;
        String attrNsURI;
        int ev = ctx.ev;

        // begin node iteration
        while (nrowsval > 0 && ev != InfosetReader.END_DOCUMENT) {
            ctx.reader.next();
            ev = ctx.reader.getEventType();

            // mixed content ?
            if (singleTextAsValue &&
                ctx.flag != 0 && ev != InfosetReader.END_ELEMENT
                              && ev != InfosetReader.CHARACTERS
                              && ev != InfosetReader.CDATA) {
                v.add(ctx.r2.getRow());
                v.addAll(ctx.v2);
                nrowsval -= ctx.v2.size() + 1;
                
                if (ctx.flag==2) {
                    ctx.nodeCount++;
                    v.add(r.getRow(ctx.nodeCount, null, "text", ctx.st.peek(), ctx.tmp_text, ""));
                    nrowsval--;
                }
                
                ctx.flag = 0;
            }
            
            switch (ev) {
                
                case InfosetReader.START_DOCUMENT:
                    ctx.st.push(null);
                    break;
                
                case InfosetReader.START_ELEMENT:

                    ctx.nodeCount++;                
                    ctx.r2.setRow(ctx.nodeCount, ctx.reader.getQName().getLocalPart(), "element",
                            ctx.st.peek(), null, ctx.reader.getQName().getNamespaceURI());
                    ctx.st.push(ctx.nodeCount);
                    
                    // handle attribute list
                    attrList = ctx.reader.getAttributes();
                    ctx.v2.clear();
                    for (int i=0; i<attrList.getLength(); i++) {
                        attrNsURI = attrList.getURI(i);
                        if (!attrNsURI.equals(XMLConstants.nameXMLNSNamespace)) {
                            ctx.nodeCount++;
                            ctx.v2.add(r.getRow(ctx.nodeCount, attrList.getLocalName(i), "attribute", 
                                    ctx.st.peek(), attrList.getValue(i), attrNsURI));
                        }
                    }
                    
                    if (singleTextAsValue) {
                        ctx.flag = 1;
                    } else {
                        v.add(ctx.r2.getRow());
                        v.addAll(ctx.v2);
                        nrowsval -= ctx.v2.size() + 1;
                    }
                    
                    break;

                case InfosetReader.END_ELEMENT:
                
                    if (singleTextAsValue) {
                        switch (ctx.flag) {
                            case 2:
                                ctx.r2.setNodeValue(ctx.tmp_text);
                            case 1:
                                v.add(ctx.r2.getRow());
                                v.addAll(ctx.v2);
                                nrowsval -= ctx.v2.size() + 1;
                                ctx.flag = 0;
                        }
                    }
             
                    ctx.st.pop();
                    break;
                
                case InfosetReader.CHARACTERS: 
                case InfosetReader.CDATA:
                    ctx.tmp_text = ctx.reader.getValue();
                    
                    if (ctx.flag==1 && singleTextAsValue) {
                        ctx.flag = 2;
                    } else {
                        ctx.nodeCount++;
                        v.add(r.getRow(ctx.nodeCount, null, "text", ctx.st.peek(), ctx.tmp_text, ""));
                        nrowsval--;
                    }
                                        
                    break;

                case InfosetReader.COMMENT:
                    ctx.nodeCount++;
                    v.add(r.getRow(ctx.nodeCount, null, "comment", ctx.st.peek(), ctx.reader.getValue(), ""));
                    nrowsval--;
                    break;

                case InfosetReader.PROCESSING_INSTRUCTION:
                    ctx.nodeCount++;
                    v.add(r.getRow(ctx.nodeCount, ctx.reader.getQName().getLocalPart(), "processing-instruction", 
                    ctx.st.peek(), ctx.reader.getValue(), ""));
                    nrowsval--;
                    break;
            } //switch(ev)
        } //while
        // return if no rows found
        if (v.isEmpty()) {
            return SUCCESS;
        }

        ctx.ev = ev;
        // create the output ARRAY using the collection
        Object out_arr[] = v.toArray();
        ArrayDescriptor ad = new ArrayDescriptor("XMLEDGETABLE",conn);
        outSet[0] = new ARRAY(ad,conn,out_arr);
        return SUCCESS;
    }

    // ===============================================================
    //    ODCITableClose
    // ===============================================================
    public BigDecimal ODCITableClose() throws SQLException {
    
        // retrieve stored context using the key, and remove from ContextManager
        StoredCtx ctx;
        try {
            ctx = (StoredCtx)ContextManager.clearContext(key.intValue());
        } catch (InvalidKeyException ik) {
            return ERROR;
        }
        return SUCCESS;
    }

}

ODCITableStart initializes the context by retrieving the binary XML stream from the input XMLType instance, and building the InfosetReader out of it :

InfosetReader rd = dec.getReader();

The other parts of the context are also initialized (array structures, element stack, node count etc.) so that they’re shared across fetches.

ODCITableFetch is where we actually read the XML stream. The method is invoked when the client asks for rows, the number of required rows is held in the nrows parameter.
For example, in a client tool such as SQL*Plus, that number is set via the ARRAYSIZE parameter.
The main part of the method consists in reading events from the InfosetReader, pretty much like we would do with a SAX parser. The big difference is that InfosetReader is a pull interface, and that’s precisely what allows us to benefit from the pipelined feature.
The logic is actually pretty straightforward : we pick up an event and act upon its nature : start of an element, comment, processing instruction etc.
However, I had to complicate it in order to manage mixed content depending on the value of the textAsValue parameter.

ODCITableClose cleans up the context.

 

Documentation references about Binary XML and the XDK for Java :

 

3. Testing

We’ll conclude by running some demos :

Connected to:
Oracle Database 11g Enterprise Edition Release 11.2.0.3.0 - Production
With the Partitioning, OLAP, Data Mining and Real Application Testing options

SQL> create table tmp_xml2 of xmltype;

Table created.

SQL> desc tmp_xml2;
 Name                                      Null?    Type
 ----------------------------------------- -------- ----------------------------
TABLE of PUBLIC.XMLTYPE STORAGE BINARY
SQL>
SQL> set scan off
SQL> insert into tmp_xml2 values(
  2   xmlparse(document
  3   '<root xmlns:ns0="special-att-ns" id="0">
  4    <item>A&BC</item>
  5    <!--This is a comment-->
  6    <item><![CDATA[12&3]]></item>
  7    <?xml-stylesheet type="text/xsl" href="style.xsl"?>
  8    <item xmlns="my.namespace.uri">
  9    123
 10      <subitem id="1"><!-- TEST --></subitem>
 11      <x:subsubitem xmlns:x="test-namespace" ns0:test="OK"> XYAZ</x:subsubitem>
 12      ABC
 13    </item>
 14  </root>'
 15   )
 16  );

1 row created.

SQL> commit;

Commit complete.

With textAsValue = 0 :

SQL> select x.*
  2  from tmp_xml2 t
  3     , table(XMLFlatten(t.object_value, 0)) x
  4  ;
 
NOD NODE_NAME            NODE_TYPE                 PAR NODE_VALUE                               NAMESPACE_URI
--- -------------------- ------------------------- --- ---------------------------------------- ------------------------------
  1 root                 element                                                                
  2 id                   attribute                   1 0                                        
  3 item                 element                     1                                          
  4                      text                        3 A&BC                                     
  5                      comment                     1 This is a comment                        
  6 item                 element                     1                                          
  7                      text                        6 12&3                                     
  8 xml-stylesheet       processing-instruction      1 type="text/xsl" href="style.xsl"         
  9 item                 element                     1                                          my.namespace.uri
 10                      text                        9                                          
                                                         123                                    
 
 11 subitem              element                     9                                          my.namespace.uri
 12 id                   attribute                  11 1                                        
 13 subsubitem           element                     9                                          test-namespace
 14 test                 attribute                  13 OK                                       special-att-ns
 15                      text                       13  XYAZ                                    
 16                      text                        9                                          
                                                           ABC                                  
 
16 rows selected

With textAsValue = 1 (elements possessing a single text() child have their values set to the text() value) :

SQL> select x.*
  2  from tmp_xml2 t
  3     , table(XMLFlatten(t.object_value, 1)) x
  4  ;
 
NOD NODE_NAME            NODE_TYPE                 PAR NODE_VALUE                               NAMESPACE_URI
--- -------------------- ------------------------- --- ---------------------------------------- ------------------------------
  1 root                 element                                                                
  2 id                   attribute                   1 0                                        
  3 item                 element                     1 A&BC                                     
  4                      comment                     1 This is a comment                        
  5 item                 element                     1 12&3                                     
  6 xml-stylesheet       processing-instruction      1 type="text/xsl" href="style.xsl"         
  7 item                 element                     1                                          my.namespace.uri
  8                      text                        7                                          
                                                         123                                    
 
  9 subitem              element                     7                                          my.namespace.uri
 10 id                   attribute                   9 1                                        
 11 subsubitem           element                     7  XYAZ                                    test-namespace
 12 test                 attribute                  11 OK                                       special-att-ns
 13                      text                        7                                          
                                                           ABC                                  
 
13 rows selected 

 

Basic “wall clock” comparisons with the XQuery and XSLT methods described in a previous article

The test file is a 4 MB spreadsheetML document (test_az.xml).
Environment : laptop PC, Windows XP 32bits, 2GB RAM

1) Current method :

SQL> truncate table tmp_xml2;

Table truncated.

SQL> insert into tmp_xml2 values(
  2    xmltype(bfilename('TEST_DIR','test_az.xml'),nls_charset_id('AL32UTF8'))
  3  );

1 row created.

SQL> commit;

Commit complete.

SQL> set autotrace traceonly statistics
SQL> set timing on
SQL> set arraysize 5000
SQL> select x.*
  2  from tmp_xml2 t
  3     , table(XMLFlatten(t.object_value, 0)) x
  4  ;

318115 rows selected.

Elapsed: 00:00:12.01

Statistics
----------------------------------------------------------
        209  recursive calls
          0  db block gets
        674  consistent gets
        327  physical reads
          0  redo size
   11876977  bytes sent via SQL*Net to client
       1113  bytes received via SQL*Net from client
         65  SQL*Net roundtrips to/from client
          2  sorts (memory)
          0  sorts (disk)
     318115  rows processed

 
2) Recursive XQuery function :

SQL> SELECT x.*
  2  FROM tmp_xml2 t
  3     , XMLTable(
  4       'declare function local:getChildren($e as node(), $pid as xs:string?) as element()*
  5        {
  6          for $i at $p in $e/(node()|@*)
  7          let $id := if ($pid) then concat($pid,".",$p) else "1"
  8          return element r
  9          {
 10            element node_id {$id}
 11          , element parent_node_id {$pid}
 12          , element node_name {local-name($i)}
 13          , element node_value { if (not($i instance of element())) then data($i) else () }
 14          , element node_type {
 15              typeswitch($i)
 16                case text()      return "text"
 17                case attribute() return "attribute"
 18                case element()   return "element"
 19                case processing-instruction() return "processing-instruction"
 20                case comment()   return "comment"
 21                default return "other"
 22            }
 23          , element namespace_uri {namespace-uri($i)}
 24          }
 25          | local:getChildren($i, $id)
 26        }; (: :)
 27        local:getChildren($d,())'
 28        passing t.object_value as "d"
 29        columns node_id         varchar2(100)   path 'node_id'
 30              , node_name       varchar2(2000)  path 'node_name'
 31              , node_value      varchar2(4000)  path 'node_value'
 32              , parent_node_id  varchar2(100)   path 'parent_node_id'
 33              , node_type       varchar2(30)    path 'node_type'
 34              , namespace_uri   varchar2(2000)  path 'namespace_uri'
 35       ) x
 36  ;

318115 rows selected.

Elapsed: 00:11:32.25

Statistics
----------------------------------------------------------
          0  recursive calls
          0  db block gets
          7  consistent gets
        327  physical reads
          0  redo size
   18004488  bytes sent via SQL*Net to client
       1113  bytes received via SQL*Net from client
         65  SQL*Net roundtrips to/from client
          0  sorts (memory)
          0  sorts (disk)
     318115  rows processed

 
3) XSLT with intermediate storage in a GTT of Binary XMLType (TMP_XML3), using this stylesheet stored in the XDB repository (xmlflatten.xsl) :

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml"/>
  <xsl:template match="/">
    <ROWSET>
      <xsl:apply-templates select="node()"/>
    </ROWSET>
  </xsl:template>
  <xsl:template match="*">
    <xsl:param name="pid"/>
    <xsl:variable name="id" select="generate-id(.)"/>
    <xsl:variable name="nsuri" select="namespace-uri(.)"/>
    <ROW id="{$id}" pid="{$pid}" name="{local-name(.)}" nsuri="{$nsuri}" type="element"/>
    <xsl:apply-templates select="node()|@*">
      <xsl:with-param name="pid">
        <xsl:value-of select="$id"/>
      </xsl:with-param>
    </xsl:apply-templates> 
  </xsl:template>
  <xsl:template match="@*">
    <xsl:param name="pid"/>
    <xsl:variable name="id" select="generate-id(.)"/>
    <xsl:variable name="nsuri" select="namespace-uri(.)"/>
    <ROW id="{$id}" pid="{$pid}" name="{local-name(.)}" nsuri="{$nsuri}" type="attribute">
      <xsl:value-of select="."/>
    </ROW>
  </xsl:template>
  <xsl:template match="text()">
    <xsl:param name="pid"/>
    <xsl:variable name="id" select="generate-id(.)"/>
    <ROW id="{$id}" pid="{$pid}" name="" nsuri="" type="text">
      <xsl:value-of select="."/>
    </ROW>
  </xsl:template>
  <xsl:template match="comment()">
    <xsl:param name="pid"/>
    <xsl:variable name="id" select="generate-id(.)"/>
    <ROW id="{$id}" pid="{$pid}" name="" nsuri="" type="comment">
      <xsl:value-of select="."/>
    </ROW>
   </xsl:template>
   <xsl:template match="processing-instruction()">
     <xsl:param name="pid"/>
     <xsl:variable name="id" select="generate-id(.)"/>
     <ROW id="{$id}" pid="{$pid}" name="" nsuri="" type="processing-instruction">
       <xsl:value-of select="."/>
     </ROW>
  </xsl:template>
</xsl:stylesheet>
SQL> insert into tmp_xml3
  2  select xmltransform(
  3           object_value
  4         , xdburitype('/public/xsl/xmlflatten.xsl').getXML()
  5         )
  6  from tmp_xml2;

1 row created.

Elapsed: 00:00:18.35

Statistics
----------------------------------------------------------
        609  recursive calls
     886061  db block gets
     124297  consistent gets
      16912  physical reads
     560896  redo size
        683  bytes sent via SQL*Net to client
        722  bytes received via SQL*Net from client
          3  SQL*Net roundtrips to/from client
         32  sorts (memory)
          0  sorts (disk)
          1  rows processed

SQL> select x.*
  2  from tmp_xml3
  3     , xmltable('/ROWSET/ROW' passing object_value
  4         columns id    varchar2(30)   path '@id'
  5               , pid   varchar2(30)   path '@pid'
  6               , name  varchar2(30)   path '@name'
  7               , nsuri varchar2(2000) path '@nsuri'
  8               , type  varchar2(30)   path '@type'
  9               , value varchar2(4000) path 'text()'
 10       ) x
 11  ;

318115 rows selected.

Elapsed: 00:00:07.29

Statistics
----------------------------------------------------------
         17  recursive calls
          2  db block gets
      45135  consistent gets
      19079  physical reads
          0  redo size
   13366039  bytes sent via SQL*Net to client
       1113  bytes received via SQL*Net from client
         65  SQL*Net roundtrips to/from client
          0  sorts (memory)
          0  sorts (disk)
     318115  rows processed

 
Conclusion :

Method Rows fetched Overall time (second)
Java pipelined function 318115 12
XSLT + XQuery (streaming eval.) 318115 26
XQuery (recursive) 318115 692

The pipelined function provides the best throughput on a large document. Close behind we find the two-step XSLT approach, then much farther the recursive XQuery method (high PGA consumption).

 

Acknowledgements : Thomas Baby and Ying Lu (from Oracle) for their help, and Marco Gralike who introduced me to the Team.

About these ads
  1. August 9, 2013 at 21:32 | #1

    Wow.

    Unluckily this kind of programming is still beyond me although I knew the power of the ODCI extendible framework. If not only that I have seen wonderful things in the hands of people of people with decent programming skills…like this Lucene implementation http://marceloochoa.blogspot.nl/search?q=ODCI

    ;-)

  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: