<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jessewheeler.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jessewheeler.dev/" rel="alternate" type="text/html" /><updated>2026-03-08T22:05:31+00:00</updated><id>https://jessewheeler.dev/feed.xml</id><title type="html">Jesse Wheeler</title><subtitle>Thoughts, stories and anecdotes around software engineering, Salesforce, and absurdities.</subtitle><entry><title type="html">bulkyard: A Salesforce CLI Plugin for Bulk Data Operations via SQLite</title><link href="https://jessewheeler.dev/salesforce/cli/data/automation/2026/03/08/bulkyard.html" rel="alternate" type="text/html" title="bulkyard: A Salesforce CLI Plugin for Bulk Data Operations via SQLite" /><published>2026-03-08T00:00:00+00:00</published><updated>2026-03-08T00:00:00+00:00</updated><id>https://jessewheeler.dev/salesforce/cli/data/automation/2026/03/08/bulkyard</id><content type="html" xml:base="https://jessewheeler.dev/salesforce/cli/data/automation/2026/03/08/bulkyard.html"><![CDATA[<h1 id="bulkyard-a-salesforce-cli-plugin-for-bulk-data-operations-via-sqlite">bulkyard: A Salesforce CLI Plugin for Bulk Data Operations via SQLite</h1>

<blockquote>
  <p><em>Because copy-pasting SOQL into Data Loader for the fifth time this sprint shouldn’t be a viable workflow.</em></p>
</blockquote>

<p>If you’ve followed along on this blog, you know I have a fondness for working with Salesforce data locally. Back in 2024, I wrote about <a href="https://jessewheeler.dev/data/automation/python/salesforce/2024/07/15/data-work.html">using SQLite3 as a staging area for Salesforce data work</a>, and earlier this year I covered <a href="https://jessewheeler.dev/salesforce/python/data/automation/2026/02/15/jupyter.html">Jupyter notebooks as a discovery tool for new org engagements</a>. The through-line in both posts was the same idea: pull data out of Salesforce, work on it locally, and push the results back.</p>

<p>The Python approach works well. <code class="language-plaintext highlighter-rouge">simple_salesforce</code> is a solid library, and <code class="language-plaintext highlighter-rouge">pandas</code> makes data manipulation pleasant. But there’s always been some boilerplate to write: authenticate, describe the object to get field names, build the SOQL query, call the Bulk API, juggle the CSVs, load them into SQLite, remember what you did and in what order. Do it for one object and it’s fine. Do it for a dozen objects as part of a repeatable data migration? It starts to feel like homework.</p>

<p>So I built <a href="https://www.npmjs.com/package/bulkyard"><code class="language-plaintext highlighter-rouge">bulkyard</code></a>, a Salesforce CLI plugin that handles the extract/load cycle between Salesforce and a local SQLite3 database. It’s open-source, installable in seconds, and driven by a YAML config file that makes multi-object operations repeatable by design.</p>

<h2 id="installing-it">Installing It</h2>

<p>If you have the Salesforce CLI installed, adding <code class="language-plaintext highlighter-rouge">bulkyard</code> is one line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf plugins <span class="nb">install </span>bulkyard
</code></pre></div></div>

<p>That’s it. No virtual environments, no <code class="language-plaintext highlighter-rouge">pip install</code>, no <code class="language-plaintext highlighter-rouge">requirements.txt</code> to manage.</p>

<h2 id="the-config-file">The Config File</h2>

<p>The heart of <code class="language-plaintext highlighter-rouge">bulkyard</code> is a YAML configuration file that describes what you want to extract or load, and from/to where. Here’s a simple example:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">database</span><span class="pi">:</span> <span class="s">bulkyard.db</span>
<span class="na">objects</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">sobject</span><span class="pi">:</span> <span class="s">Account</span>
    <span class="na">query</span><span class="pi">:</span> <span class="s2">"</span><span class="s">SELECT</span><span class="nv"> </span><span class="s">Id,</span><span class="nv"> </span><span class="s">Name,</span><span class="nv"> </span><span class="s">Industry,</span><span class="nv"> </span><span class="s">Website</span><span class="nv"> </span><span class="s">FROM</span><span class="nv"> </span><span class="s">Account</span><span class="nv"> </span><span class="s">WHERE</span><span class="nv"> </span><span class="s">CreatedDate</span><span class="nv"> </span><span class="s">=</span><span class="nv"> </span><span class="s">LAST_N_DAYS:90"</span>
  <span class="pi">-</span> <span class="na">sobject</span><span class="pi">:</span> <span class="s">Contact</span>
    <span class="na">query</span><span class="pi">:</span> <span class="s2">"</span><span class="s">SELECT</span><span class="nv"> </span><span class="s">Id,</span><span class="nv"> </span><span class="s">AccountId,</span><span class="nv"> </span><span class="s">FirstName,</span><span class="nv"> </span><span class="s">LastName,</span><span class="nv"> </span><span class="s">Email</span><span class="nv"> </span><span class="s">FROM</span><span class="nv"> </span><span class="s">Contact</span><span class="nv"> </span><span class="s">WHERE</span><span class="nv"> </span><span class="s">Account.CreatedDate</span><span class="nv"> </span><span class="s">=</span><span class="nv"> </span><span class="s">LAST_N_DAYS:90"</span>
</code></pre></div></div>

<p>This tells <code class="language-plaintext highlighter-rouge">bulkyard</code> to extract two objects into a local SQLite file called <code class="language-plaintext highlighter-rouge">bulkyard.db</code>. The tables are named after the SObject by default, though you can override that too.</p>

<p>The value of a config file over a one-off script makes it so that, six weeks from now, you (or a colleague) can open the YAML and immediately understand exactly what was extracted and why. If you can’t run it again cleanly, it’s not a workflow.</p>

<h2 id="extracting-data">Extracting Data</h2>

<p>With the config in place, extracting is straightforward:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf bulkyard extract <span class="nt">--config-file</span> config.yaml <span class="nt">--target-org</span> my-sandbox
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bulkyard</code> uses the <strong>Bulk API 2.0</strong> under the hood. It describes each object to build proper type mappings, creates typed SQLite tables, and inserts records in transactional batches of 1,000 rows. You end up with a local <code class="language-plaintext highlighter-rouge">.db</code> file that you can query with any SQLite tool (DBeaver, the <code class="language-plaintext highlighter-rouge">sqlite3</code> CLI, or from a Jupyter notebook if that’s your jam).</p>

<p>If you want to skip the config file for a quick one-off extraction, you can pass the object and query directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf bulkyard extract <span class="se">\</span>
  <span class="nt">--sobject</span> Opportunity <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"SELECT Id, Name, StageName, Amount FROM Opportunity WHERE IsClosed = FALSE"</span> <span class="se">\</span>
  <span class="nt">--database</span> opps.db <span class="se">\</span>
  <span class="nt">--target-org</span> my-sandbox
</code></pre></div></div>

<p>That kind of flexibility matters during discovery work. Sometimes you want a full config-driven pipeline. Sometimes you just want to grab a table and start poking at it.</p>

<h2 id="transforming-locally">Transforming Locally</h2>

<p>Once the data is in SQLite, you’re in familiar territory. Everything from the <a href="https://jessewheeler.dev/data/automation/python/salesforce/2024/07/15/data-work.html">2024 data work post</a> applies here: SQL JOINs for relationship work, Pandas for anything more complex, or even just running queries in a GUI to sanity-check what you’ve got.</p>

<p>The key difference is that <code class="language-plaintext highlighter-rouge">bulkyard</code> eliminated the extraction boilerplate. By the time you’re writing your first transformation query, the data is already there.</p>

<p>For example, after extracting <code class="language-plaintext highlighter-rouge">Account</code> and <code class="language-plaintext highlighter-rouge">Contact</code>, you might produce a cleaned <code class="language-plaintext highlighter-rouge">Contact</code> table for a migration scenario:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">Contact_Clean</span> <span class="k">AS</span>
<span class="k">SELECT</span>
    <span class="k">c</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span>
    <span class="k">c</span><span class="p">.</span><span class="n">AccountId</span><span class="p">,</span>
    <span class="k">c</span><span class="p">.</span><span class="n">FirstName</span><span class="p">,</span>
    <span class="k">c</span><span class="p">.</span><span class="n">LastName</span><span class="p">,</span>
    <span class="k">c</span><span class="p">.</span><span class="n">Email</span>
<span class="k">FROM</span> <span class="n">Contact</span> <span class="k">c</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">Account</span> <span class="n">a</span> <span class="k">ON</span> <span class="k">c</span><span class="p">.</span><span class="n">AccountId</span> <span class="o">=</span> <span class="n">a</span><span class="p">.</span><span class="n">Id</span>
<span class="k">WHERE</span> <span class="k">c</span><span class="p">.</span><span class="n">Email</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span>
  <span class="k">AND</span> <span class="n">a</span><span class="p">.</span><span class="n">Industry</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="loading-data-back">Loading Data Back</h2>

<p>When you’re ready to push data back into Salesforce, <code class="language-plaintext highlighter-rouge">bulkyard load</code> handles it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf bulkyard load <span class="se">\</span>
  <span class="nt">--sobject</span> Contact <span class="se">\</span>
  <span class="nt">--operation</span> upsert <span class="se">\</span>
  <span class="nt">--external-id-field</span> Id <span class="se">\</span>
  <span class="nt">--database</span> opps.db <span class="se">\</span>
  <span class="nt">--target-org</span> my-production-org
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--operation</code> flag supports <code class="language-plaintext highlighter-rouge">insert</code>, <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">upsert</code>, and <code class="language-plaintext highlighter-rouge">delete</code>, so the full CRUD surface is covered. For upsert operations, you specify the external ID field. Typically, that’s <code class="language-plaintext highlighter-rouge">Id</code> for updates within an org, or a custom external ID field for cross-org migrations.</p>

<p>For multi-object loads, this is where the config file can pay off:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">database</span><span class="pi">:</span> <span class="s">bulkyard.db</span>
<span class="na">objects</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">sobject</span><span class="pi">:</span> <span class="s">Account</span>
    <span class="na">operation</span><span class="pi">:</span> <span class="s">upsert</span>
    <span class="na">external_id_field</span><span class="pi">:</span> <span class="s">External_Id__c</span>
  <span class="pi">-</span> <span class="na">sobject</span><span class="pi">:</span> <span class="s">Contact</span>
    <span class="na">operation</span><span class="pi">:</span> <span class="s">upsert</span>
    <span class="na">external_id_field</span><span class="pi">:</span> <span class="s">External_Id__c</span>
    <span class="na">table</span><span class="pi">:</span> <span class="s">Contact_Clean</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">table</code> override on <code class="language-plaintext highlighter-rouge">Contact</code> is how you tell <code class="language-plaintext highlighter-rouge">bulkyard</code> to load from <code class="language-plaintext highlighter-rouge">Contact_Clean</code> instead of the raw <code class="language-plaintext highlighter-rouge">Contact</code> table. Run it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf bulkyard load <span class="nt">--config-file</span> config.yaml <span class="nt">--target-org</span> my-production-org
</code></pre></div></div>

<p>And that’s your full pipeline: extract, transform locally, load back. All config-driven, all repeatable.</p>

<h2 id="a-lesson-about-jsforce">A Lesson About jsforce</h2>

<p>I want to share something that bit me during development, because it’ll probably bite you too if you reach for the same tooling.</p>

<p>When I started working on <code class="language-plaintext highlighter-rouge">bulkyard</code>, the obvious path for Bulk API 2.0 operations in a TypeScript SF CLI plugin is <code class="language-plaintext highlighter-rouge">conn.bulk2.query()</code> from <code class="language-plaintext highlighter-rouge">@salesforce/core</code>’s Connection class (which wraps jsforce under the hood). The API looks clean:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The first approach — looks fine, isn't</span>
<span class="kd">const</span> <span class="nx">queryResult</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">conn</span><span class="p">.</span><span class="nx">bulk2</span><span class="p">.</span><span class="nx">query</span><span class="p">(</span><span class="nx">soqlQuery</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">records</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">queryResult</span><span class="p">.</span><span class="nx">toArray</span><span class="p">();</span>
</code></pre></div></div>

<p>This works. Right up until you point it at an object with a meaningful number of records, at which point it times out. The fix was to drop the abstraction entirely and implement the job lifecycle directly against the REST endpoints: POST a query job, poll until it completes, then walk the paginated CSV results page by page until complete.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The actual implementation</span>
<span class="kd">const</span> <span class="nx">jobInfo</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">conn</span><span class="p">.</span><span class="nx">requestPost</span><span class="o">&lt;</span><span class="nx">QueryJobInfo</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">jobsUrl</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">operation</span><span class="p">:</span> <span class="dl">'</span><span class="s1">query</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">query</span><span class="p">:</span> <span class="nx">soqlQuery</span><span class="p">,</span>
<span class="p">});</span>

<span class="k">await</span> <span class="nx">waitForJobCompletion</span><span class="p">(</span><span class="nx">conn</span><span class="p">,</span> <span class="nx">jobInfo</span><span class="p">.</span><span class="nx">id</span><span class="p">);</span>

<span class="kd">let</span> <span class="nx">locator</span><span class="p">:</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">do</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">locator</span> <span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">resultsUrl</span><span class="p">}</span><span class="s2">?locator=</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">locator</span><span class="p">)}</span><span class="s2">`</span> <span class="p">:</span> <span class="nx">resultsUrl</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span> <span class="na">headers</span><span class="p">:</span> <span class="nx">authHeaders</span> <span class="p">});</span>
  <span class="nx">locator</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">sforce-locator</span><span class="dl">'</span><span class="p">);</span>
  <span class="c1">// parse CSV page, batch insert...</span>
<span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="nx">locator</span> <span class="o">&amp;&amp;</span> <span class="nx">locator</span> <span class="o">!==</span> <span class="dl">'</span><span class="s1">null</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p>This is not unusual territory if you’ve worked with the Bulk API 2.0 REST spec directly, but it’s a meaningful gap in the jsforce abstraction that I didn’t know. If you’re building anything on top of the Bulk API 2.0 in TypeScript and care about completeness for large datasets, go to the REST endpoints.</p>

<h2 id="why-this-approach">Why This Approach?</h2>

<p>What <code class="language-plaintext highlighter-rouge">bulkyard</code> adds is a tighter integration with the Salesforce CLI, which most developers already have installed, and a configuration model that makes the extract/load cycle feel effortless.</p>

<p>If you’re already using the SF CLI day-to-day, <code class="language-plaintext highlighter-rouge">bulkyard</code> should slot into your workflow without friction. And if you’ve been building Python scripts to wrangle data between Salesforce and SQLite, consider whether you want to keep maintaining that boilerplate or just offload the plumbing.</p>

<h2 id="check-it-out">Check It Out</h2>

<p><code class="language-plaintext highlighter-rouge">bulkyard</code> is open-source. You can find the source on <a href="https://github.com/jessewheeler/bulkyard">GitHub</a> and install it from <a href="https://www.npmjs.com/package/bulkyard">npm</a>. Issues and PRs are welcome.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sf plugins <span class="nb">install </span>bulkyard
</code></pre></div></div>

<p>Happy data wrangling!</p>]]></content><author><name></name></author><category term="salesforce" /><category term="cli" /><category term="data" /><category term="automation" /><summary type="html"><![CDATA[bulkyard: A Salesforce CLI Plugin for Bulk Data Operations via SQLite]]></summary></entry><entry><title type="html">Jupyter Notebooks: Your Secret Weapon for Salesforce Org Discovery</title><link href="https://jessewheeler.dev/salesforce/python/data/automation/2026/02/15/jupyter.html" rel="alternate" type="text/html" title="Jupyter Notebooks: Your Secret Weapon for Salesforce Org Discovery" /><published>2026-02-15T00:00:00+00:00</published><updated>2026-02-15T00:00:00+00:00</updated><id>https://jessewheeler.dev/salesforce/python/data/automation/2026/02/15/jupyter</id><content type="html" xml:base="https://jessewheeler.dev/salesforce/python/data/automation/2026/02/15/jupyter.html"><![CDATA[<p>Every Salesforce consultant has been there. You get the keys to a new org, log in, and immediately feel that familiar mix of curiosity and dread. <em>What’s in here? How bad is it? Where are the land mines?</em></p>

<p>You could click around Setup for a few hours. You could ask the client a bunch of questions (and get a bunch of “I think maybe someone set that up a few years ago?” answers). Or, you could do what I’ve been doing lately: fire up a Jupyter notebook and let the org tell you its own story.</p>

<h2 id="why-a-notebook">Why a Notebook?</h2>

<p>I’ve been reaching for Jupyter notebooks more and more during client discovery, and at this point I’m not sure how I ever lived without them. The appeal boils down to a few things.</p>

<p>First, notebooks are iterative. You can run a query, look at the results, decide what to dig into next, and keep going without losing context. It’s the closest thing to having a conversation with a Salesforce org.</p>

<p>Second, they’re self-documenting. When you’re done poking around, you don’t just have a pile of SOQL queries in a text file. You have a narrative (think markdown headings, inline results, and charts) that you can hand to a colleague or even share with the client. It’s a living artifact of your discovery process.</p>

<p>Third, and this is the one that sold me, they’re reusable. I’ve built up a standard org analysis notebook that I bring to every new engagement. I connect it to the org, hit “Run All,” and within a few minutes I have a high-level picture of what I’m working with. It’s not a replacement for real discovery, but it’s a phenomenal starting point.</p>

<h2 id="the-setup">The Setup</h2>

<p>The tooling is straightforward. I use <a href="https://github.com/simple-salesforce/simple-salesforce">simple_salesforce</a> as a Python client for the Salesforce REST API, <code class="language-plaintext highlighter-rouge">pandas</code> for wrangling data into DataFrames, and <code class="language-plaintext highlighter-rouge">matplotlib</code> (with <code class="language-plaintext highlighter-rouge">seaborn</code> for aesthetics because I have <em>some</em> self-respect) for visualization.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>
<span class="kn">import</span> <span class="nn">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>
<span class="kn">import</span> <span class="nn">seaborn</span>
<span class="kn">from</span> <span class="nn">simple_salesforce</span> <span class="kn">import</span> <span class="n">Salesforce</span>

<span class="n">seaborn</span><span class="p">.</span><span class="nb">set</span><span class="p">()</span>
</code></pre></div></div>

<p>The notebook is designed to run either locally or in Google Colab, which is nice when you’re pairing with someone who doesn’t have a local Python environment set up.</p>

<h3 id="a-quick-but-important-note-about-credentials">A Quick but Important Note About Credentials</h3>

<p>This is the part where I get on a small soapbox. <strong>Never hard-code your Salesforce credentials.</strong> Not in the notebook, not in a config file you commit to Git, not anywhere that isn’t explicitly designed for secrets management.</p>

<p>When running in Google Colab, I pull credentials from Colab’s <code class="language-plaintext highlighter-rouge">userdata</code> secrets store:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">google.colab</span> <span class="kn">import</span> <span class="n">userdata</span>

<span class="n">sf</span> <span class="o">=</span> <span class="n">Salesforce</span><span class="p">(</span>
    <span class="n">username</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_USERNAME'</span><span class="p">),</span>
    <span class="n">password</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_PASSWORD'</span><span class="p">),</span>
    <span class="n">security_token</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_SECURITY_TOKEN'</span><span class="p">),</span>
    <span class="n">consumer_key</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_CLIENT_ID'</span><span class="p">),</span>
    <span class="n">consumer_secret</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_CLIENT_SECRET'</span><span class="p">),</span>
    <span class="n">domain</span><span class="o">=</span><span class="n">userdata</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'SF_DOMAIN'</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>When running locally, I load from an environment file that is <code class="language-plaintext highlighter-rouge">.gitignored</code> into oblivion:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">json</span>

<span class="n">filename</span> <span class="o">=</span> <span class="sa">f</span><span class="s">'./envs/</span><span class="si">{</span><span class="n">env_name</span><span class="si">}</span><span class="s">.json'</span>
<span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">filename</span><span class="p">,</span> <span class="s">'r'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="n">creds</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>

<span class="n">sf</span> <span class="o">=</span> <span class="n">Salesforce</span><span class="p">(</span>
    <span class="n">username</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'username'</span><span class="p">],</span>
    <span class="n">password</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'password'</span><span class="p">],</span>
    <span class="n">security_token</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'security_token'</span><span class="p">],</span>
    <span class="n">consumer_key</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'client_id'</span><span class="p">],</span>
    <span class="n">consumer_secret</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'client_secret'</span><span class="p">],</span>
    <span class="n">domain</span><span class="o">=</span><span class="n">creds</span><span class="p">[</span><span class="s">'host'</span><span class="p">]</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The environment file itself is just a simple JSON blob with your connected app credentials and login info. The key point is that it stays local. It never touches version control. If you’re using <code class="language-plaintext highlighter-rouge">dotenv</code> or OS-level environment variables instead, even better. The principle is the same: <strong>secrets stay secret</strong>.</p>

<p>(Stepping off soapbox now.)</p>

<h2 id="what-the-notebook-actually-does">What the Notebook Actually Does</h2>

<p>Here’s the rough structure of my standard discovery notebook. Think of it as a checklist of the things I want to know about any org before I start making promises.</p>

<h3 id="1-api-limits-am-i-about-to-ruin-someones-day">1. API Limits: Am I About to Ruin Someone’s Day?</h3>

<p>The very first thing the notebook does after authenticating is check the org’s daily API request usage. This matters because the notebook itself will consume API calls, and if you’re connecting to a production org that’s already running hot, you want to know <em>before</em> you fire off a few hundred SOQL queries.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">limits</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">limits</span><span class="p">()</span>
<span class="n">api_usage</span> <span class="o">=</span> <span class="n">limits</span><span class="p">[</span><span class="s">'DailyApiRequests'</span><span class="p">]</span>
<span class="n">consumed</span> <span class="o">=</span> <span class="n">api_usage</span><span class="p">[</span><span class="s">'Max'</span><span class="p">]</span> <span class="o">-</span> <span class="n">api_usage</span><span class="p">[</span><span class="s">'Remaining'</span><span class="p">]</span>
<span class="n">pct</span> <span class="o">=</span> <span class="nb">round</span><span class="p">(</span><span class="n">consumed</span> <span class="o">/</span> <span class="n">api_usage</span><span class="p">[</span><span class="s">'Max'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span> <span class="o">*</span> <span class="mi">100</span>

<span class="k">if</span> <span class="n">pct</span> <span class="o">&gt;</span> <span class="mi">80</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">'WARNING: </span><span class="si">{</span><span class="n">pct</span><span class="si">}</span><span class="s">% consumed. Halting.'</span><span class="p">)</span>
    <span class="k">raise</span> <span class="n">StopExecution</span>
<span class="k">else</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">'</span><span class="si">{</span><span class="n">pct</span><span class="si">}</span><span class="s">% of daily API requests consumed. Proceeding.'</span><span class="p">)</span>
</code></pre></div></div>

<p>I built in a <code class="language-plaintext highlighter-rouge">StopExecution</code> exception that halts the notebook if API consumption is above 80%. It’s a small thing, but it’s saved me from an awkward conversation at least once.</p>

<h3 id="2-apex-code-size-how-much-custom-code-am-i-inheriting">2. Apex Code Size: How Much Custom Code Am I Inheriting?</h3>

<p>Next up: getting a sense of the codebase. I query all active Apex classes (excluding managed packages and test classes) and calculate each one’s size as a percentage of Salesforce’s character limit. This gives me a quick feel for how much custom code exists and where the big files live.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Find test classes via SOSL
</span><span class="n">test_classes</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">search</span><span class="p">(</span>
    <span class="s">"FIND {@isTest} IN ALL FIELDS RETURNING ApexClass(Id, Name)"</span>
<span class="p">)</span>

<span class="c1"># Query all active, unpackaged Apex classes
</span><span class="n">all_classes</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT Id, Name, Body, LengthWithoutComments "</span>
    <span class="s">"FROM ApexClass "</span>
    <span class="s">"WHERE Status = 'Active' AND NamespacePrefix = null"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>From there, it’s just a matter of calculating byte sizes and sorting. A class sitting at 3% of the org’s total Apex limit is worth investigating. A handful of large utility classes from an open-source logging library? Probably fine. A 50KB class called <code class="language-plaintext highlighter-rouge">DataFactory</code> with no clear naming convention? That’s where you start asking questions.</p>

<h3 id="3-automation-inventory-scanning-for-land-mines">3. Automation Inventory: Scanning for Land Mines</h3>

<p>This section is the one I find most valuable in early discovery. Salesforce orgs have a tendency to accumulate automation like a junk drawer accumulates takeout menus. Process Builders, record-triggered flows, Apex triggers… they all pile up. Understanding what fires when, and on which objects, is critical.</p>

<p><strong>Process Builders:</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">process_builders</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT ApiName, Label, InstalledPackageName, VersionNumber "</span>
    <span class="s">"FROM FlowDefinitionView "</span>
    <span class="s">"WHERE IsActive = TRUE AND ProcessType = 'Workflow'"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Just seeing the count here tells you something. A handful of Process Builders on a mature org? Normal. Fourteen of them, some from managed packages, some clearly hand-rolled years ago? That’s a migration conversation waiting to happen (since Salesforce is sunsetting Process Builder in favor of Flows).</p>

<p><strong>Record-Triggered Flows:</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">flows</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT ApiName, TriggerType, Label, TriggerObjectOrEventLabel, "</span>
    <span class="s">"RecordTriggerType, TriggerOrder "</span>
    <span class="s">"FROM FlowDefinitionView "</span>
    <span class="s">"WHERE IsActive = TRUE "</span>
    <span class="s">"AND (TriggerType='RecordAfterSave' OR TriggerType='RecordBeforeSave')"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>I chart these by object label to see which objects are the busiest. If an object has multiple before-save flows, after-save flows, and Apex triggers, that’s a land mine. You need to understand the order of execution and whether they’re stepping on each other.</p>

<p><strong>Apex Triggers:</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">triggers</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT Name, TableEnumOrId, NamespacePrefix, Status, "</span>
    <span class="s">"UsageAfterInsert, UsageAfterUpdate, UsageBeforeInsert, UsageBeforeUpdate "</span>
    <span class="s">"FROM ApexTrigger WHERE Status = 'Active'"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>I split these into managed package triggers versus unpackaged triggers. Managed package triggers (like the ones from CPQ, which can have dozens) are largely out of your control but important to be aware of. Unpackaged triggers are the ones you’ll be maintaining, and visualizing them by object and trigger event gives you an instant sense of complexity.</p>

<h3 id="4-storage-and-object-usage-whats-actually-in-this-org">4. Storage and Object Usage: What’s Actually in This Org?</h3>

<p>I query every customizable SObject in the org and get record counts for each. This takes a minute (it’s a lot of queries), but the result is a clear picture of which objects are heavily used and which are ghost towns.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">entities</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query_all</span><span class="p">(</span>
    <span class="s">"SELECT QualifiedApiName "</span>
    <span class="s">"FROM EntityDefinition "</span>
    <span class="s">"WHERE IsCustomSetting = FALSE AND IsCustomizable = TRUE"</span>
<span class="p">)</span>

<span class="c1"># Then, for each object:
</span><span class="n">count</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="sa">f</span><span class="s">"SELECT COUNT() FROM </span><span class="si">{</span><span class="n">object_api_name</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Objects with zero records are interesting. Did someone build this out and never use it? Objects with millions of records are interesting too. Is storage going to be a concern? I also plot record growth over time for the top objects by querying <code class="language-plaintext highlighter-rouge">CreatedDate</code> on recent records. A sudden spike in record creation usually has a story behind it.</p>

<h3 id="5-user-adoption-is-anyone-actually-using-this-thing">5. User Adoption: Is Anyone Actually Using This Thing?</h3>

<p>Finally, I look at licensing and user activity. License utilization tells you whether the org is right-sized or if you’re paying for seats nobody’s sitting in. User activity (specifically <code class="language-plaintext highlighter-rouge">LastLoginDate</code>) tells you who’s engaged and who’s not.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">licenses</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT Name, TotalLicenses, UsedLicenses, Status "</span>
    <span class="s">"FROM UserLicense"</span>
<span class="p">)</span>

<span class="n">users</span> <span class="o">=</span> <span class="n">sf</span><span class="p">.</span><span class="n">query</span><span class="p">(</span>
    <span class="s">"SELECT Name, IsActive, Profile.Name, UserRole.Name, "</span>
    <span class="s">"LastLoginDate, Profile.UserLicense.Name "</span>
    <span class="s">"FROM User "</span>
    <span class="s">"WHERE IsActive = TRUE AND UserType != 'AutomatedProcess'"</span>
<span class="p">)</span>
</code></pre></div></div>

<p>A user who hasn’t logged in for 90+ days is worth flagging. A pile of unused Salesforce Platform licenses? That’s a cost optimization conversation.</p>

<h2 id="the-bigger-picture">The Bigger Picture</h2>

<p>None of this replaces talking to people. You still need to sit down with admins, developers, and end users to understand <em>why</em> things are the way they are. But running this notebook gives you a massive head start. You walk into those conversations already knowing which objects are busiest, where the automation is tangled, and which corners of the org haven’t been touched in years.</p>

<p>It also earns you a bit of credibility. There’s something about saying “I noticed you have fourteen active Process Builders and a before-save flow on Opportunity that appears to overlap with an Apex trigger” that signals you’ve done your homework.</p>

<p>If you’re a Salesforce consultant or developer who hasn’t dabbled in Jupyter notebooks yet, I’d genuinely encourage you to give it a try. The learning curve for basic <code class="language-plaintext highlighter-rouge">pandas</code> and <code class="language-plaintext highlighter-rouge">simple_salesforce</code> is small, and the payoff in terms of discovery speed is enormous. And once you have your template notebook, every new org is just a <code class="language-plaintext highlighter-rouge">Shift+Enter</code> away from being a lot less mysterious.</p>

<p>Happy exploring.</p>]]></content><author><name></name></author><category term="salesforce" /><category term="python" /><category term="data" /><category term="automation" /><summary type="html"><![CDATA[Every Salesforce consultant has been there. You get the keys to a new org, log in, and immediately feel that familiar mix of curiosity and dread. What’s in here? How bad is it? Where are the land mines?]]></summary></entry><entry><title type="html">SQLite3 and Other Critical Tools for Salesforce Data Work</title><link href="https://jessewheeler.dev/data/automation/python/salesforce/2024/07/15/data-work.html" rel="alternate" type="text/html" title="SQLite3 and Other Critical Tools for Salesforce Data Work" /><published>2024-07-15T00:00:00+00:00</published><updated>2024-07-15T00:00:00+00:00</updated><id>https://jessewheeler.dev/data/automation/python/salesforce/2024/07/15/data-work</id><content type="html" xml:base="https://jessewheeler.dev/data/automation/python/salesforce/2024/07/15/data-work.html"><![CDATA[<h1 id="sqlite3-and-other-critical-tools-for-salesforce-data-work">SQLite3 and Other Critical Tools for Salesforce Data Work</h1>

<blockquote>
  <p><em>SQLite? More Like SQLightning!</em></p>
</blockquote>

<h2 id="introduction">Introduction</h2>

<p>Working with Salesforce data be daunting. Whether you’re migrating data from one org to another, transforming data for
a new integration, or simply cleaning up data, the process can be time-consuming and error-prone. 
In this post, we’ll explore some tools and techniques that can help streamline the process and make your life easier.</p>

<h2 id="guiding-principles">Guiding Principles</h2>

<p>Like any other development work, a clear set of goals is essential to success. Here are a few goals to consider when
working with Salesforce data:</p>
<ul>
  <li><strong>Efficiency</strong>: Minimize the time and effort required to complete the task. Automation is key here.</li>
  <li><strong>Accuracy</strong>: Ensure that the data is migrated, transformed, or cleaned up correctly. This means clear tests need to be defined.</li>
</ul>

<p>and, most importantly;</p>
<ul>
  <li><strong>Repeatability</strong>: Make the process repeatable so that it can be run multiple times without errors, 
  in an <a href="https://en.wikipedia.org/wiki/Idempotence">idempotent</a> manner.</li>
</ul>

<h2 id="meet-sqlite3-the-swiss-army-knife-of-databases">Meet SQLite3, the Swiss Army Knife of Databases</h2>

<p><a href="https://www.sqlite.org/">SQLite3</a> is a lightweight, serverless, and self-contained SQL database engine. It’s perfect 
for this kind of work, as we don’t need to worry about concurrent database writes or setting up a database server. 
Lastly, it’s extremely portable, as it’s just a single file that can be easily shared.</p>

<p>A common practice is to use SQLite3 as a staging area for the data we’ll work with. We can easily extract/import data 
from a source system or series of files and persist the data in dedicated SQLite tables. Then, we can run our 
transformations and validations against this data before loading it into Salesforce.</p>

<p>Lastly, a table <code class="language-plaintext highlighter-rouge">JOIN</code> beats the pants off of a <code class="language-plaintext highlighter-rouge">VLOOKUP</code> operation any day of the week.</p>

<h2 id="python-pandas-and-simple-salesforce">Python, Pandas, and Simple-Salesforce</h2>

<p>In addition to SQLite, we’ll need a few additional tools. We’ll leverage Python throughout this post, 
but Ruby (with the <a href="https://github.com/restforce/restforce">Restforce</a> gem) or Node.js 
(with the <a href="https://jsforce.github.io/">jsforce</a> library) could be used as well.</p>

<ul>
  <li><a href="https://github.com/simple-salesforce/simple-salesforce">Simple-Salesforce</a> is a Python library that provides a 
  simple and easy-to-use interface to interact with Salesforce.</li>
  <li><a href="https://pandas.pydata.org/">Pandas</a> is a powerful data manipulation library that provides data structures and 
  functions to quickly and efficiently manipulate data.</li>
</ul>

<blockquote>
  <p><strong>Friendly reminder: never store your Salesforce credentials in your code. Use environment variables or
a secure configuration file.</strong></p>
</blockquote>

<h3 id="setting-up-your-python-environment">Setting up your Python Environment</h3>

<p>To get started, you’ll need to install the required Python libraries. You can do this using <code class="language-plaintext highlighter-rouge">pip</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>simple-salesforce pandas
</code></pre></div></div>

<h3 id="heads-up-its-always-a-good-idea-to-use-a-virtual-environment-to-manage-your-python-dependencies">Heads up: it’s always a good idea to use a virtual environment to manage your Python dependencies.</h3>
<p>More detail on setting up a virtual environment can be found <a href="https://docs.python.org/3/library/venv.html">here</a>.</p>

<h2 id="putting-it-all-together">Putting It All Together</h2>

<p>Let’s walk through a simple example to illustrate how these tools can be used together.</p>

<h3 id="step-1">Step 1</h3>

<p><a href="https://help.salesforce.com/s/articleView?id=sf.connected_app_create.htm&amp;language=en_US&amp;type=5">Create a new Connected App in Salesforce</a>
and obtain the client ID and client secret. You’ll also need your user’s <a href="https://help.salesforce.com/s/articleView?id=sf.user_security_token.htm&amp;language=en_US&amp;type=5">security token</a>.
You’ll need to enable OAuth settings for the app, and the scope for the connected app should include <code class="language-plaintext highlighter-rouge">full</code>
and <code class="language-plaintext highlighter-rouge">refresh_token</code>.</p>

<h3 id="step-2">Step 2</h3>

<p>Store your credentials in a secure manner. I recommend using environment variables, but we’ll use a configuration file 
for this example:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"username"</span><span class="p">:</span><span class="w">       </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"password"</span><span class="p">:</span><span class="w">       </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"security_token"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"client_id"</span><span class="p">:</span><span class="w">      </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"client_secret"</span><span class="p">:</span><span class="w">  </span><span class="s2">""</span><span class="p">,</span><span class="w">
    </span><span class="nl">"api_version"</span><span class="p">:</span><span class="w">    </span><span class="s2">"61.0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"host"</span><span class="p">:</span><span class="w">           </span><span class="s2">"test"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="step-3">Step 3</h3>

<p>Authenticate with Salesforce using Simple-Salesforce. Here’s a simple function to build the connection using the 
configuration file:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">simple_salesforce</span> <span class="kn">import</span> <span class="n">Salesforce</span>
<span class="kn">import</span> <span class="nn">json</span>

<span class="n">env_file</span> <span class="o">=</span> <span class="nb">open</span><span class="p">(</span><span class="s">"./myconfigurationfile.json"</span><span class="p">,</span> <span class="s">'r'</span><span class="p">)</span>
<span class="n">sf_creds</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">env_file</span><span class="p">.</span><span class="n">read</span><span class="p">())</span>

<span class="n">un</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'username'</span><span class="p">]</span>
<span class="n">pw</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'password'</span><span class="p">]</span>
<span class="n">st</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'security_token'</span><span class="p">]</span>
<span class="n">ck</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'client_id'</span><span class="p">]</span>
<span class="n">cs</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'client_secret'</span><span class="p">]</span>
<span class="n">en</span> <span class="o">=</span> <span class="n">sf_creds</span><span class="p">[</span><span class="s">'host'</span><span class="p">]</span>

<span class="n">sf</span> <span class="o">=</span> <span class="n">Salesforce</span><span class="p">(</span><span class="n">username</span><span class="o">=</span><span class="n">un</span><span class="p">,</span> <span class="n">password</span><span class="o">=</span><span class="n">pw</span><span class="p">,</span> <span class="n">security_token</span><span class="o">=</span><span class="n">st</span><span class="p">,</span> <span class="n">consumer_key</span><span class="o">=</span><span class="n">ck</span><span class="p">,</span> <span class="n">consumer_secret</span><span class="o">=</span><span class="n">cs</span><span class="p">,</span> <span class="n">domain</span><span class="o">=</span><span class="n">en</span><span class="p">)</span>
</code></pre></div></div>
<p>(Yes, it’s really that straightforward.)</p>

<h3 id="step-4">Step 4</h3>

<p>Extract data from Salesforce. In this example, we’ll extract data from the <code class="language-plaintext highlighter-rouge">Account</code> 
SObject and dynamically retrieve all fields:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sobject_type</span> <span class="o">=</span> <span class="s">"Account"</span>
<span class="n">sobject</span> <span class="o">=</span> <span class="nb">getattr</span><span class="p">(</span><span class="n">sf</span><span class="p">,</span> <span class="n">sobject_type</span><span class="p">)</span>
<span class="n">description</span> <span class="o">=</span> <span class="n">sobject</span><span class="p">.</span><span class="n">describe</span><span class="p">()</span>
<span class="n">field_names</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">compound_fields</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span>
<span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">description</span><span class="p">[</span><span class="s">"fields"</span><span class="p">]:</span>
    <span class="n">field_names</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">field</span><span class="p">[</span><span class="s">"name"</span><span class="p">])</span>
    <span class="k">if</span> <span class="n">field</span><span class="p">[</span><span class="s">"compoundFieldName"</span><span class="p">]</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="ow">and</span> <span class="n">field</span><span class="p">[</span><span class="s">"name"</span><span class="p">]</span> <span class="o">!=</span> <span class="s">"Name"</span><span class="p">:</span>
        <span class="c1"># compound fields should not be queried directly
</span>        <span class="n">compound_fields</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">field</span><span class="p">[</span><span class="s">"compoundFieldName"</span><span class="p">])</span>

<span class="c1"># remove compound fields
</span><span class="n">field_names</span> <span class="o">=</span> <span class="p">[</span><span class="n">field</span> <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">field_names</span> <span class="k">if</span> <span class="n">field</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">compound_fields</span><span class="p">]</span>

<span class="c1"># dynamically define bulk2 type
</span><span class="n">sobject</span> <span class="o">=</span> <span class="nb">getattr</span><span class="p">(</span><span class="n">sf_client</span><span class="p">.</span><span class="n">bulk2</span><span class="p">,</span> <span class="n">sobject_type</span><span class="p">)</span>
<span class="n">query</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"SELECT </span><span class="si">{</span><span class="s">', '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">field_names</span><span class="p">)</span><span class="si">}</span><span class="s"> FROM </span><span class="si">{</span><span class="n">sobject_name</span><span class="si">}</span><span class="s">"</span>
<span class="n">sobject</span><span class="p">.</span><span class="n">download</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">path</span><span class="o">=</span><span class="s">'./temp_data'</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="step-5">Step 5</h3>

<p>Now that we have our data locally as CSV files, we can load them into a SQLite3 table:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">sqlite3</span>
<span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">from</span> <span class="nn">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="c1"># create a new SQLite3 database
</span><span class="n">db</span> <span class="o">=</span> <span class="n">sqlite3</span><span class="p">.</span><span class="n">connect</span><span class="p">(</span><span class="s">'salesforce_db.sqlite3'</span><span class="p">)</span>

<span class="c1"># for each CSV file in the data directory, load it into a SQLite3 table
</span><span class="k">for</span> <span class="n">csv</span> <span class="ow">in</span> <span class="n">Path</span><span class="p">(</span><span class="s">'./temp_data'</span><span class="p">).</span><span class="n">glob</span><span class="p">(</span><span class="s">"*.csv"</span><span class="p">):</span>
    <span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">read_csv</span><span class="p">(</span><span class="n">csv</span><span class="p">)</span>
    <span class="n">df</span><span class="p">.</span><span class="n">to_sql</span><span class="p">(</span><span class="s">"Account"</span><span class="p">,</span> <span class="n">db</span><span class="p">,</span> <span class="n">if_exists</span><span class="o">=</span><span class="s">'append'</span><span class="p">)</span>
    
<span class="c1"># then, we can clean up the CSV files
</span><span class="k">for</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">os</span><span class="p">.</span><span class="n">listdir</span><span class="p">(</span><span class="s">'./temp_data'</span><span class="p">):</span>
    <span class="n">file_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="s">'./temp_data'</span><span class="p">,</span> <span class="n">filename</span><span class="p">)</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">isfile</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span> <span class="ow">or</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">islink</span><span class="p">(</span><span class="n">file_path</span><span class="p">):</span>
            <span class="n">os</span><span class="p">.</span><span class="n">unlink</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span>
        <span class="k">elif</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">isdir</span><span class="p">(</span><span class="n">file_path</span><span class="p">):</span>
            <span class="n">shutil</span><span class="p">.</span><span class="n">rmtree</span><span class="p">(</span><span class="n">file_path</span><span class="p">)</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="s">'Failed to delete %s. Reason: %s'</span> <span class="o">%</span> <span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="n">e</span><span class="p">))</span>
</code></pre></div></div>

<h3 id="step-6">Step 6</h3>

<p>Now that we have our data in SQLite3, we can run our transformations and validate the outputs. There’s a number of ways 
to do this, including running additional Python scripts, but we’ll leverage plain ol’ SQL for this example:</p>

<p>Here, we’ll create a new table <code class="language-plaintext highlighter-rouge">Account_Clean</code> and remove any records where the <code class="language-plaintext highlighter-rouge">Website</code> or <code class="language-plaintext highlighter-rouge">Industry</code> field is empty:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">Account_Clean</span> <span class="k">AS</span>
<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">Account</span>
<span class="k">WHERE</span> <span class="n">Website</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">OR</span> <span class="n">Industry</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="step-7">Step 7</h3>

<p>Finally, if we had modified data, we can load the records into Salesforce via the Bulk API:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pd</span><span class="p">.</span><span class="n">read_sql_query</span><span class="p">(</span><span class="s">"SELECT * FROM Account_Clean"</span><span class="p">,</span> <span class="n">db</span><span class="p">).</span><span class="n">to_csv</span><span class="p">(</span><span class="s">"temp-data.csv"</span><span class="p">,</span> <span class="n">sep</span><span class="o">=</span><span class="s">","</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
<span class="n">sobject</span> <span class="o">=</span> <span class="n">salesforce_client</span><span class="p">.</span><span class="n">bulk2</span><span class="p">.</span><span class="n">__getattr__</span><span class="p">(</span><span class="s">"Account"</span><span class="p">)</span>
<span class="n">results</span> <span class="o">=</span> <span class="n">sobject</span><span class="p">.</span><span class="n">upsert</span><span class="p">(</span><span class="s">"temp-data.csv"</span><span class="p">,</span> <span class="n">external_id_field</span><span class="o">=</span><span class="s">"Id"</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>SQLite3, Python, and Simple-Salesforce are incredibly powerful tools that can make your data work more efficient, 
repeatable, and accurate. By leveraging these tools, you can streamline your data migration, transformation, and cleanup 
processes and focus on the more important (and fun) aspects of your work.</p>]]></content><author><name></name></author><category term="data" /><category term="automation" /><category term="python" /><category term="salesforce" /><summary type="html"><![CDATA[SQLite3 and Other Critical Tools for Salesforce Data Work]]></summary></entry><entry><title type="html">Why Another Blog?</title><link href="https://jessewheeler.dev/misc/2024/07/13/why-another-blog.html" rel="alternate" type="text/html" title="Why Another Blog?" /><published>2024-07-13T00:00:00+00:00</published><updated>2024-07-13T00:00:00+00:00</updated><id>https://jessewheeler.dev/misc/2024/07/13/why-another-blog</id><content type="html" xml:base="https://jessewheeler.dev/misc/2024/07/13/why-another-blog.html"><![CDATA[<p>Embarking on this blogging journey, I hope to dive into a world of continuous learning and self-discovery. 
As a software engineer, I constantly seek ways to enhance my understanding of the tools, platforms, and languages 
I work in and strive to keep current with the latest technological advancements.</p>

<p>Through this blog, I aim to share my knowledge and experiences, spark discussions and connect with a community of 
like-minded individuals. By documenting my professional journey and personal interests, I hope to refine my 
communication skills and gain new perspectives that will enrich both my career and personal life.</p>]]></content><author><name></name></author><category term="misc" /><summary type="html"><![CDATA[Embarking on this blogging journey, I hope to dive into a world of continuous learning and self-discovery. As a software engineer, I constantly seek ways to enhance my understanding of the tools, platforms, and languages I work in and strive to keep current with the latest technological advancements.]]></summary></entry></feed>