feat(loader): bundle native library inside the JAR (#77)
diff --git a/core/pom.xml b/core/pom.xml
index 5eddb3b..524282d 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -34,6 +34,12 @@
 
     <name>Apache DataFusion Java</name>
 
+    <properties>
+        <!-- Which cargo build profile (debug/release) the bundled native
+             library is copied from. Override with -Ddatafusion.native.profile=release. -->
+        <datafusion.native.profile>debug</datafusion.native.profile>
+    </properties>
+
     <dependencies>
         <dependency>
             <groupId>org.junit.jupiter</groupId>
@@ -77,10 +83,35 @@
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
                 <configuration>
-                    <argLine>-Djava.library.path=${maven.multiModuleProjectDirectory}/native/target/debug --add-opens=java.base/java.nio=ALL-UNNAMED</argLine>
+                    <argLine>--add-opens=java.base/java.nio=ALL-UNNAMED</argLine>
                 </configuration>
             </plugin>
             <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-antrun-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>copy-native-lib</id>
+                        <phase>process-classes</phase>
+                        <goals><goal>run</goal></goals>
+                        <configuration>
+                            <target>
+                                <property name="datafusion.native.lib.source"
+                                          value="${maven.multiModuleProjectDirectory}/native/target/${datafusion.native.profile}/${datafusion.lib.filename}"/>
+                                <fail message="Native library not found at ${datafusion.native.lib.source}. Run 'cd native &amp;&amp; cargo build' (or 'make') before building the JAR.">
+                                    <condition><not><available file="${datafusion.native.lib.source}"/></not></condition>
+                                </fail>
+                                <mkdir dir="${project.build.outputDirectory}/org/apache/datafusion/${datafusion.lib.os}/${datafusion.lib.arch}"/>
+                                <copy file="${datafusion.native.lib.source}"
+                                      tofile="${project.build.outputDirectory}/org/apache/datafusion/${datafusion.lib.os}/${datafusion.lib.arch}/${datafusion.lib.filename}"
+                                      preservelastmodified="true"
+                                      verbose="true"/>
+                            </target>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
                 <groupId>com.googlecode.maven-download-plugin</groupId>
                 <artifactId>download-maven-plugin</artifactId>
                 <version>1.9.0</version>
@@ -149,4 +180,85 @@
             </plugin>
         </plugins>
     </build>
+
+    <profiles>
+        <!--
+            One of these profiles activates per host so the antrun copy
+            knows the OS/arch directory and library filename to use.
+            Resource layout follows the bundling convention:
+
+              org/apache/datafusion/linux/amd64/libdatafusion_jni.so
+              org/apache/datafusion/linux/aarch64/libdatafusion_jni.so
+              org/apache/datafusion/darwin/x86_64/libdatafusion_jni.dylib
+              org/apache/datafusion/darwin/aarch64/libdatafusion_jni.dylib
+              org/apache/datafusion/windows/amd64/datafusion_jni.dll
+        -->
+        <profile>
+            <id>native-linux-amd64</id>
+            <activation>
+                <os><family>unix</family><name>linux</name><arch>amd64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>linux</datafusion.lib.os>
+                <datafusion.lib.arch>amd64</datafusion.lib.arch>
+                <datafusion.lib.filename>libdatafusion_jni.so</datafusion.lib.filename>
+            </properties>
+        </profile>
+        <profile>
+            <id>native-linux-aarch64</id>
+            <activation>
+                <os><family>unix</family><name>linux</name><arch>aarch64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>linux</datafusion.lib.os>
+                <datafusion.lib.arch>aarch64</datafusion.lib.arch>
+                <datafusion.lib.filename>libdatafusion_jni.so</datafusion.lib.filename>
+            </properties>
+        </profile>
+        <profile>
+            <id>native-mac-x86_64</id>
+            <activation>
+                <os><family>mac</family><arch>x86_64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>darwin</datafusion.lib.os>
+                <datafusion.lib.arch>x86_64</datafusion.lib.arch>
+                <datafusion.lib.filename>libdatafusion_jni.dylib</datafusion.lib.filename>
+            </properties>
+        </profile>
+        <profile>
+            <!-- Some JVMs report os.arch=amd64 even on macOS x86_64. -->
+            <id>native-mac-amd64</id>
+            <activation>
+                <os><family>mac</family><arch>amd64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>darwin</datafusion.lib.os>
+                <datafusion.lib.arch>x86_64</datafusion.lib.arch>
+                <datafusion.lib.filename>libdatafusion_jni.dylib</datafusion.lib.filename>
+            </properties>
+        </profile>
+        <profile>
+            <id>native-mac-aarch64</id>
+            <activation>
+                <os><family>mac</family><arch>aarch64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>darwin</datafusion.lib.os>
+                <datafusion.lib.arch>aarch64</datafusion.lib.arch>
+                <datafusion.lib.filename>libdatafusion_jni.dylib</datafusion.lib.filename>
+            </properties>
+        </profile>
+        <profile>
+            <id>native-windows-amd64</id>
+            <activation>
+                <os><family>windows</family><arch>amd64</arch></os>
+            </activation>
+            <properties>
+                <datafusion.lib.os>windows</datafusion.lib.os>
+                <datafusion.lib.arch>amd64</datafusion.lib.arch>
+                <datafusion.lib.filename>datafusion_jni.dll</datafusion.lib.filename>
+            </properties>
+        </profile>
+    </profiles>
 </project>
diff --git a/core/src/main/java/org/apache/datafusion/NativeLibraryLoader.java b/core/src/main/java/org/apache/datafusion/NativeLibraryLoader.java
index 2c74341..426bd7d 100644
--- a/core/src/main/java/org/apache/datafusion/NativeLibraryLoader.java
+++ b/core/src/main/java/org/apache/datafusion/NativeLibraryLoader.java
@@ -19,16 +19,96 @@
 
 package org.apache.datafusion;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Loads the {@code datafusion_jni} native library on demand.
+ *
+ * <p>The loader first tries {@link System#loadLibrary(String)} so that operators can override the
+ * bundled library by placing a build on {@code java.library.path} (for example via {@code
+ * -Djava.library.path=...} or {@code LD_LIBRARY_PATH}). If that fails the loader extracts the
+ * platform-specific library from the JAR resource tree and loads it via {@link
+ * System#load(String)}.
+ *
+ * <p>The bundled libraries live at the conventional path {@code
+ * org/apache/datafusion/&lt;os&gt;/&lt;arch&gt;/&lt;libfile&gt;}. Each JVM extracts into its own
+ * private temporary directory created with {@link Files#createTempDirectory(String,
+ * java.nio.file.attribute.FileAttribute[])}, which is mode 0700 on POSIX and user-private under the
+ * default Windows {@code TMP}. Keeping the directory private prevents another user on the host from
+ * planting a same-named library for the JVM to pick up.
+ */
 public final class NativeLibraryLoader {
-  private static final String LIBRARY_NAME = "datafusion_jni";
-  private static boolean loaded = false;
+
+  private static final String TMP_DIR_PREFIX = "datafusion-java-";
+
+  private static volatile boolean loaded;
 
   private NativeLibraryLoader() {}
 
   public static synchronized void loadLibrary() {
-    if (!loaded) {
-      System.loadLibrary(LIBRARY_NAME);
-      loaded = true;
+    if (loaded) {
+      return;
     }
+    if (tryLoadFromLibraryPath()) {
+      loaded = true;
+      return;
+    }
+    loadFromClasspath();
+    loaded = true;
+  }
+
+  private static boolean tryLoadFromLibraryPath() {
+    try {
+      System.loadLibrary(Platform.LIBRARY_NAME);
+      return true;
+    } catch (UnsatisfiedLinkError ignored) {
+      return false;
+    }
+  }
+
+  private static void loadFromClasspath() {
+    Platform platform = Platform.current();
+    String resource = platform.resourcePath();
+    try (InputStream check = NativeLibraryLoader.class.getResourceAsStream(resource)) {
+      if (check == null) {
+        throw new UnsatisfiedLinkError(
+            "No bundled datafusion_jni library for "
+                + platform
+                + " (expected classpath:"
+                + resource
+                + ")."
+                + " Build the native crate and add it to java.library.path,"
+                + " or depend on a JAR built for this platform.");
+      }
+    } catch (IOException e) {
+      throw linkError("Failed to probe " + resource, e);
+    }
+
+    try {
+      Path extracted = extractToPrivateTempDir(resource, platform.libFileName());
+      System.load(extracted.toAbsolutePath().toString());
+    } catch (IOException e) {
+      throw linkError("Failed to extract " + resource, e);
+    }
+  }
+
+  private static Path extractToPrivateTempDir(String resource, String fileName) throws IOException {
+    Path tmpDir = Files.createTempDirectory(TMP_DIR_PREFIX);
+    tmpDir.toFile().deleteOnExit();
+    Path target = tmpDir.resolve(fileName);
+    target.toFile().deleteOnExit();
+    try (InputStream in = NativeLibraryLoader.class.getResourceAsStream(resource)) {
+      Files.copy(in, target);
+    }
+    return target;
+  }
+
+  private static UnsatisfiedLinkError linkError(String message, Throwable cause) {
+    UnsatisfiedLinkError err = new UnsatisfiedLinkError(message + ": " + cause.getMessage());
+    err.initCause(cause);
+    return err;
   }
 }
diff --git a/core/src/main/java/org/apache/datafusion/Platform.java b/core/src/main/java/org/apache/datafusion/Platform.java
new file mode 100644
index 0000000..9c3f0a5
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/Platform.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+import java.util.Locale;
+
+/**
+ * Identifies a target OS/arch pair and the path at which the bundled native library is published
+ * inside the JAR.
+ *
+ * <p>Resource layout follows the convention from the {@code datafusion-java} packaging design:
+ *
+ * <pre>
+ *   org/apache/datafusion/linux/amd64/libdatafusion_jni.so
+ *   org/apache/datafusion/linux/aarch64/libdatafusion_jni.so
+ *   org/apache/datafusion/darwin/x86_64/libdatafusion_jni.dylib
+ *   org/apache/datafusion/darwin/aarch64/libdatafusion_jni.dylib
+ *   org/apache/datafusion/windows/amd64/datafusion_jni.dll
+ * </pre>
+ *
+ * <p>Package-private; consumed only by {@link NativeLibraryLoader}.
+ */
+final class Platform {
+
+  static final String LIBRARY_NAME = "datafusion_jni";
+  static final String RESOURCE_PREFIX = "org/apache/datafusion";
+
+  enum Os {
+    LINUX("linux", "lib", "so"),
+    DARWIN("darwin", "lib", "dylib"),
+    WINDOWS("windows", "", "dll");
+
+    final String dirName;
+    final String libPrefix;
+    final String libSuffix;
+
+    Os(String dirName, String libPrefix, String libSuffix) {
+      this.dirName = dirName;
+      this.libPrefix = libPrefix;
+      this.libSuffix = libSuffix;
+    }
+  }
+
+  final Os os;
+  final String arch;
+
+  private Platform(Os os, String arch) {
+    this.os = os;
+    this.arch = arch;
+  }
+
+  static Platform current() {
+    return of(System.getProperty("os.name"), System.getProperty("os.arch"));
+  }
+
+  static Platform of(String osName, String osArch) {
+    Os os = detectOs(osName);
+    String arch = detectArch(os, osArch);
+    return new Platform(os, arch);
+  }
+
+  static Os detectOs(String osName) {
+    if (osName == null) {
+      throw new UnsupportedOperationException("os.name is not set");
+    }
+    String n = osName.toLowerCase(Locale.ROOT);
+    if (n.startsWith("linux")) {
+      return Os.LINUX;
+    }
+    if (n.startsWith("mac") || n.contains("darwin")) {
+      return Os.DARWIN;
+    }
+    if (n.startsWith("windows")) {
+      return Os.WINDOWS;
+    }
+    throw new UnsupportedOperationException("Unsupported OS for datafusion_jni: " + osName);
+  }
+
+  /**
+   * Returns the architecture segment used in the resource path for {@code os}.
+   *
+   * <p>Linux and Windows use {@code amd64} (Java's preferred name for x86_64), while macOS uses
+   * {@code x86_64}; all use {@code aarch64} for ARM64.
+   */
+  static String detectArch(Os os, String osArch) {
+    if (osArch == null) {
+      throw new UnsupportedOperationException("os.arch is not set");
+    }
+    String n = osArch.toLowerCase(Locale.ROOT);
+    boolean isX64 = n.equals("amd64") || n.equals("x86_64") || n.equals("x64");
+    boolean isArm64 = n.equals("aarch64") || n.equals("arm64");
+    if (isX64) {
+      return os == Os.DARWIN ? "x86_64" : "amd64";
+    }
+    if (isArm64) {
+      return "aarch64";
+    }
+    throw new UnsupportedOperationException(
+        "Unsupported CPU architecture for datafusion_jni: " + osArch);
+  }
+
+  String libFileName() {
+    return os.libPrefix + LIBRARY_NAME + "." + os.libSuffix;
+  }
+
+  /**
+   * Absolute classpath resource path (with leading slash) of the bundled native library for this
+   * platform.
+   */
+  String resourcePath() {
+    return "/" + RESOURCE_PREFIX + "/" + os.dirName + "/" + arch + "/" + libFileName();
+  }
+
+  @Override
+  public String toString() {
+    return os.dirName + "/" + arch;
+  }
+}
diff --git a/core/src/test/java/org/apache/datafusion/PlatformTest.java b/core/src/test/java/org/apache/datafusion/PlatformTest.java
new file mode 100644
index 0000000..21f0ee3
--- /dev/null
+++ b/core/src/test/java/org/apache/datafusion/PlatformTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class PlatformTest {
+
+  @Test
+  void detectsLinuxFromOsName() {
+    assertEquals(Platform.Os.LINUX, Platform.detectOs("Linux"));
+    assertEquals(Platform.Os.LINUX, Platform.detectOs("linux"));
+  }
+
+  @Test
+  void detectsDarwinFromOsName() {
+    assertEquals(Platform.Os.DARWIN, Platform.detectOs("Mac OS X"));
+    assertEquals(Platform.Os.DARWIN, Platform.detectOs("macOS"));
+    assertEquals(Platform.Os.DARWIN, Platform.detectOs("Darwin"));
+  }
+
+  @Test
+  void detectsWindowsFromOsName() {
+    assertEquals(Platform.Os.WINDOWS, Platform.detectOs("Windows 10"));
+    assertEquals(Platform.Os.WINDOWS, Platform.detectOs("Windows Server 2019"));
+  }
+
+  @Test
+  void rejectsUnknownOs() {
+    assertThrows(UnsupportedOperationException.class, () -> Platform.detectOs("Solaris"));
+  }
+
+  @Test
+  void rejectsNullOs() {
+    assertThrows(UnsupportedOperationException.class, () -> Platform.detectOs(null));
+  }
+
+  @Test
+  void usesAmd64OnLinuxForX86_64Aliases() {
+    assertEquals("amd64", Platform.detectArch(Platform.Os.LINUX, "amd64"));
+    assertEquals("amd64", Platform.detectArch(Platform.Os.LINUX, "x86_64"));
+    assertEquals("amd64", Platform.detectArch(Platform.Os.LINUX, "x64"));
+  }
+
+  @Test
+  void usesX86_64OnDarwinForX86_64Aliases() {
+    assertEquals("x86_64", Platform.detectArch(Platform.Os.DARWIN, "amd64"));
+    assertEquals("x86_64", Platform.detectArch(Platform.Os.DARWIN, "x86_64"));
+  }
+
+  @Test
+  void usesAmd64OnWindowsForX86_64Aliases() {
+    assertEquals("amd64", Platform.detectArch(Platform.Os.WINDOWS, "amd64"));
+    assertEquals("amd64", Platform.detectArch(Platform.Os.WINDOWS, "x86_64"));
+    assertEquals("amd64", Platform.detectArch(Platform.Os.WINDOWS, "x64"));
+  }
+
+  @Test
+  void usesAarch64ForArm64Aliases() {
+    assertEquals("aarch64", Platform.detectArch(Platform.Os.LINUX, "aarch64"));
+    assertEquals("aarch64", Platform.detectArch(Platform.Os.LINUX, "arm64"));
+    assertEquals("aarch64", Platform.detectArch(Platform.Os.DARWIN, "aarch64"));
+    assertEquals("aarch64", Platform.detectArch(Platform.Os.DARWIN, "arm64"));
+  }
+
+  @Test
+  void rejectsUnknownArch() {
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> Platform.detectArch(Platform.Os.LINUX, "ppc64le"));
+  }
+
+  @Test
+  void libFileNameUsesPlatformConventions() {
+    assertEquals("libdatafusion_jni.so", Platform.of("Linux", "amd64").libFileName());
+    assertEquals("libdatafusion_jni.so", Platform.of("Linux", "aarch64").libFileName());
+    assertEquals("libdatafusion_jni.dylib", Platform.of("Mac OS X", "x86_64").libFileName());
+    assertEquals("libdatafusion_jni.dylib", Platform.of("Mac OS X", "aarch64").libFileName());
+    assertEquals("datafusion_jni.dll", Platform.of("Windows 11", "amd64").libFileName());
+  }
+
+  @Test
+  void resourcePathMatchesSpec() {
+    assertEquals(
+        "/org/apache/datafusion/linux/amd64/libdatafusion_jni.so",
+        Platform.of("Linux", "amd64").resourcePath());
+    assertEquals(
+        "/org/apache/datafusion/linux/aarch64/libdatafusion_jni.so",
+        Platform.of("Linux", "aarch64").resourcePath());
+    assertEquals(
+        "/org/apache/datafusion/darwin/x86_64/libdatafusion_jni.dylib",
+        Platform.of("Mac OS X", "x86_64").resourcePath());
+    assertEquals(
+        "/org/apache/datafusion/darwin/aarch64/libdatafusion_jni.dylib",
+        Platform.of("Mac OS X", "aarch64").resourcePath());
+    assertEquals(
+        "/org/apache/datafusion/windows/amd64/datafusion_jni.dll",
+        Platform.of("Windows 11", "amd64").resourcePath());
+  }
+}
diff --git a/examples/pom.xml b/examples/pom.xml
index 97a6b40..96c9ad5 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -75,7 +75,6 @@
                 <configuration>
                     <executable>java</executable>
                     <arguments>
-                        <argument>-Djava.library.path=${maven.multiModuleProjectDirectory}/native/target/debug</argument>
                         <argument>--add-opens=java.base/java.nio=ALL-UNNAMED</argument>
                         <argument>-classpath</argument>
                         <classpath/>